Issue — claude-sdk-oauth: thinking-block timing fields flip the commit-boundary hash → assistant_rewritten on nearly every turn → repeated full-history re-send
Repository: code-yeongyu/senpi
Version: 2026.8.3-3 (npm latest — registry 확인됨)
Provider: claude-sdk-oauth
관련 컴포넌트: dist/core/extensions/builtin/claude-sdk-oauth/ (session-commit-boundary.js, session-registry-wiring.js, session-continuity.js, session-stream.js, session-sync.js, session-registry.js) + @earendil-works/pi-agent-core (agent-loop.js) + @earendil-works/pi-ai (tool-call-middleware)
Summary
claude-sdk-oauth provider에서 thinking 블록이 포함된 턴의 assistant 메시지가 매번 "rewritten"으로 오판되어, 다음 턴이 항상 fork/flatten(전체·대량 히스토리 재전송)을 수행합니다. 장기 세션에서 재전송 규모(count = user/toolResult 메시지 수)가 히스토리와 함께 커져 최대 132까지 증가합니다.
실측 세션 로그(~/.senpi/agent/logs/session.log)에서 claude_sdk_oauth_session_continuity 이벤트 130건 집계:
| 결정 |
의미 |
횟수 |
delta |
새 메시지만 전송 (효율·바람직) |
6 |
flatten |
전체 대화 재전송 (비효율) |
67 |
fork |
이전 경계부터 재전송 |
57 |
| — |
reason assistant_rewritten |
115 (flatten 60 + fork 55) |
재현 완료 (아래 증거): 0-토큰 hermetic harness와 실제 SDK(최소 토큰) 모두에서 동일 패턴 재현 — turn 1 → turn 2가 assistant_rewritten으로 flatten.
원인 (확정 — 계측으로 capture/commit 해시 diff 확인)
assistant 메시지의 thinking 콘텐츠 블록에 startedAt/endedAt(스트리밍 타이밍 메타데이터)이 capture와 commit 사이에 달라집니다.
코드 경로:
pi-agent-core의 agent-loop.js streamAssistantResponse()는 스트리밍 중 thinking 이벤트에서 block.startedAt = timing.startedAt; block.endedAt = timing.endedAt을 설정한 뒤 message_update를 발행하고, 턴 종료(done) 시 propagateThinkingTiming(finalMessage)로 또 한 번 startedAt/endedAt을 타이밍 맵 값으로 재설정한다.
@earendil-works/pi-ai의 tool-call-middleware(recovery-stream-wrapper → StreamThinkingProjection, preserveSourceMetadata: true)는 thinking 이벤트를 프로젝션된 별도 메시지로 내보낸다. cloneThinking()이 {...block, thinking}을 만들어 프로젝션 메시지에 넣으므로, agent-loop가 타이밍 필드를 붙이는 thinking 블록은 원본 output의 블록과 다른 객체다. text 이벤트가 내보낼 때는 원본 경로가 쓰인다.
- 결과적으로 마지막
message_update가 관찰하는 content 와 message_end가 관찰하는 content(finally에서 propagateThinkingTiming이 적용된 원본 output) 의 thinking 블록에 startedAt/endedAt 유무가 갈라진다.
session-commit-boundary.js의 assistantContentHash는 content 전체(sessionSyncDigest)를 해시하므로 이 메타데이터 차이가 해시를 바꾼다 → commit()이 "rewritten" 반환.
session-registry-wiring.js의 message_end 핸들러가 recordPendingFork(sessionId, "assistant_rewritten") 기록.
- 다음 턴
session-continuity.js decideNativeContinuity()가 pendingForkReason을 보고 forkOrFlatten; boundaryBefore가 sentCount 이전 assistant uuid를 못 찾거나 먼 경계를 잡으면 flatten/fork.
- flatten은 새 SDK 세션을 열고
buildPromptBlocks로 전체 대화를 재주입. recordSyncedStream이 sentCount = hashes.length로 올리고 새 assistant uuid는 assistantUuidByIndex[sentCount]에 기록 → 다음 턴 boundaryBefore(sentCount)가 경계를 못 찾아 flatten이 재발하는 구조. 관측된 긴 flatten 연속 구간과 일치.
재현 증거 (계측: AssistantCommitBoundary.prototype을 패치해 capture/commit content diff 로그)
Tier 1 — hermetic fake SDK (0 토큰, overrideSdkBoundary)
생각 블록 + 텍스트 블록을 흘려보내는 fake SDK로 실제 파이프라인(createAgentSession → prompt ×2)을 구동:
[OBS] continuity: {"kind":"bootstrap","reason":"registry_miss","deltaMessages":1} ← turn 1
[INSTRUMENT] REWRITTEN — content-only diff:
.content.0.startedAt: MISSING in capture (1785819137555)
.content.0.endedAt: MISSING in capture (1785819137555)
capture content: [{"type":"thinking","thinking":"…","thinkingSignature":""},{"type":"text","text":"…","index":1}]
commit content: [{"type":"thinking","thinking":"…","thinkingSignature":"","startedAt":…,"endedAt":…},{"type":"text","text":"…","index":1}]
[OBS] continuity: {"kind":"flatten","reason":"assistant_rewritten","deltaMessages":2} ← turn 2
→ turn 1은 정상 bootstrap, turn 2는 assistant_rewritten flatten. — 운영 로그의 지배 패턴과 동일.
Tier 2 — 실제 SDK (최소 토큰: sonnet-5, "say hi" × 2)
실제 Claude Code SDK로 같은 파이프라인 구동:
[OBS] continuity: {"kind":"flatten","reason":"registry_miss","deltaMessages":2} ← turn 1
[INSTRUMENT] REWRITTEN — thinking 블록 startedAt/endedAt MISSING in capture, present in commit
[OBS] continuity: {"kind":"flatten","reason":"assistant_rewritten","deltaMessages":3} ← turn 2
→ 실제 thinking(+signature)이 포함된 턴에서 capture에는 startedAt/endedAt이 없고 commit에는 존재 → 해시 불일치 → rewritten → turn 2 flatten.
(turn 2의 자체 commit은 clean으로 판정 — turn 2가 flatten인 이유는 turn 1 말에 기록된 pending fork 때문. 즉 "thinking 턴 ⇒ 다음 턴 무조건 재전송"이 정확한 설명.)
관측 (로그 기반, 검증됨)
긴 연속 구간 (모두 reason=assistant_rewritten):
| 구간 |
시작 → 끝 |
count |
| fork 54건 |
02:10:04Z → 03:08:29Z |
2 → 62 |
| flatten 37건 |
01:11:03Z → 01:45:09Z |
8 → 93 |
| flatten 20건 |
01:45:47Z → 01:50:56Z |
96 → 125 |
| flatten 3건 (피크) |
02:07:05Z → 02:07:35Z |
129 → 132 |
- delta로 복귀하는 턴도 존재 (
01:51:13Z, 02:07:52Z, 02:09:40Z, 03:08:56Z) — "영원히 복귀 불가"는 아니지만 130턴 중 115턴(≈88%)이 재전송 판정이 지배 패턴.
count는 토큰이 아닌 deltaMessages(user/toolResult 메시지 해시 수). 값이 커진다는 것은 재전송 대상 히스토리가 계속 누적 성장한다는 뜻.
- ⚠️ 로그에 session ID가 없어 구간별 동일 세션 여부는 시간 연속성으로만 추정.
compaction.log 수치(emergency_prune 662185, idle_trigger 756380/773376)도 이 구간과 식별자로 연결 불가(참고용).
Suggested fix (제안)
- 커밋 경계 지문을 '안정적인 페이로드'로 정의 — 휘발성 표시/텔레메트리 필드(
startedAt/endedAt, 스트리밍 index 등)를 해시에서 제외하거나, message_end의 후처리된 메시지 대신 SDK 세션에 실제로 보낸 델타/flatten 페이로드를 지문으로 사용.
- capture 시점을 후처리 이후로 이동 —
captureProviderFinal이 마지막 stream mutation 이후(예: assistant 메시지 최종본)를 잡도록 순서 조정. 단 프로젝션 경로상 원본/클론 불일치가 있으므로 1안이 더 견고.
- 리뷰에서 확인된 사실:
captureProviderFinal은 이미 digest 문자열을 즉시 저장하므로 "capture 시 딥카피"는 이 문제를 해결하지 못함 — 지문 정의 변경이 필요.
Evidence / logs
~/.senpi/agent/logs/session.log — continuity 130건 (모든 수치의 원천).
- 재현 harness:
overrideSdkBoundary(Tier 1, 0토큰) / 실 SDK(Tier 2) × AssistantCommitBoundary.prototype 계측. 원하시면 스크립트 전체를 공유하겠습니다.
- 설치 버전: senpi
2026.8.3-3, @earendil-works/pi-agent-core@2026.8.3-3(npm: @code-yeongyu/senpi-agent-core@2026.8.3-3).
Env
- senpi:
2026.8.3-3 (npm latest)
- provider:
claude-sdk-oauth (실 SDK 테스트: claude-sonnet-5)
- OS: macOS (arm64)
Issue —
claude-sdk-oauth: thinking-block timing fields flip the commit-boundary hash →assistant_rewrittenon nearly every turn → repeated full-history re-sendSummary
claude-sdk-oauthprovider에서 thinking 블록이 포함된 턴의 assistant 메시지가 매번 "rewritten"으로 오판되어, 다음 턴이 항상 fork/flatten(전체·대량 히스토리 재전송)을 수행합니다. 장기 세션에서 재전송 규모(count= user/toolResult 메시지 수)가 히스토리와 함께 커져 최대 132까지 증가합니다.실측 세션 로그(
~/.senpi/agent/logs/session.log)에서claude_sdk_oauth_session_continuity이벤트 130건 집계:deltaflattenforkassistant_rewritten재현 완료 (아래 증거): 0-토큰 hermetic harness와 실제 SDK(최소 토큰) 모두에서 동일 패턴 재현 — turn 1 → turn 2가
assistant_rewritten으로 flatten.원인 (확정 — 계측으로 capture/commit 해시 diff 확인)
assistant 메시지의 thinking 콘텐츠 블록에
startedAt/endedAt(스트리밍 타이밍 메타데이터)이 capture와 commit 사이에 달라집니다.코드 경로:
pi-agent-core의agent-loop.jsstreamAssistantResponse()는 스트리밍 중 thinking 이벤트에서block.startedAt = timing.startedAt; block.endedAt = timing.endedAt을 설정한 뒤message_update를 발행하고, 턴 종료(done) 시propagateThinkingTiming(finalMessage)로 또 한 번startedAt/endedAt을 타이밍 맵 값으로 재설정한다.@earendil-works/pi-ai의tool-call-middleware(recovery-stream-wrapper→StreamThinkingProjection,preserveSourceMetadata: true)는 thinking 이벤트를 프로젝션된 별도 메시지로 내보낸다.cloneThinking()이{...block, thinking}을 만들어 프로젝션 메시지에 넣으므로, agent-loop가 타이밍 필드를 붙이는 thinking 블록은 원본 output의 블록과 다른 객체다. text 이벤트가 내보낼 때는 원본 경로가 쓰인다.message_update가 관찰하는 content 와message_end가 관찰하는 content(finally에서propagateThinkingTiming이 적용된 원본 output) 의 thinking 블록에startedAt/endedAt유무가 갈라진다.session-commit-boundary.js의assistantContentHash는content전체(sessionSyncDigest)를 해시하므로 이 메타데이터 차이가 해시를 바꾼다 →commit()이"rewritten"반환.session-registry-wiring.js의message_end핸들러가recordPendingFork(sessionId, "assistant_rewritten")기록.session-continuity.jsdecideNativeContinuity()가pendingForkReason을 보고forkOrFlatten;boundaryBefore가sentCount이전 assistant uuid를 못 찾거나 먼 경계를 잡으면 flatten/fork.buildPromptBlocks로 전체 대화를 재주입.recordSyncedStream이sentCount = hashes.length로 올리고 새 assistant uuid는assistantUuidByIndex[sentCount]에 기록 → 다음 턴boundaryBefore(sentCount)가 경계를 못 찾아 flatten이 재발하는 구조. 관측된 긴 flatten 연속 구간과 일치.재현 증거 (계측:
AssistantCommitBoundary.prototype을 패치해 capture/commit content diff 로그)Tier 1 — hermetic fake SDK (0 토큰,
overrideSdkBoundary)생각 블록 + 텍스트 블록을 흘려보내는 fake SDK로 실제 파이프라인(
createAgentSession→prompt×2)을 구동:→ turn 1은 정상 bootstrap, turn 2는
assistant_rewrittenflatten. — 운영 로그의 지배 패턴과 동일.Tier 2 — 실제 SDK (최소 토큰: sonnet-5, "say hi" × 2)
실제 Claude Code SDK로 같은 파이프라인 구동:
→ 실제 thinking(+signature)이 포함된 턴에서 capture에는
startedAt/endedAt이 없고 commit에는 존재 → 해시 불일치 → rewritten → turn 2 flatten.(turn 2의 자체 commit은 clean으로 판정 — turn 2가 flatten인 이유는 turn 1 말에 기록된 pending fork 때문. 즉 "thinking 턴 ⇒ 다음 턴 무조건 재전송"이 정확한 설명.)
관측 (로그 기반, 검증됨)
긴 연속 구간 (모두
reason=assistant_rewritten):02:10:04Z→03:08:29Z01:11:03Z→01:45:09Z01:45:47Z→01:50:56Z02:07:05Z→02:07:35Z01:51:13Z,02:07:52Z,02:09:40Z,03:08:56Z) — "영원히 복귀 불가"는 아니지만 130턴 중 115턴(≈88%)이 재전송 판정이 지배 패턴.count는 토큰이 아닌deltaMessages(user/toolResult 메시지 해시 수). 값이 커진다는 것은 재전송 대상 히스토리가 계속 누적 성장한다는 뜻.compaction.log수치(emergency_prune662185,idle_trigger756380/773376)도 이 구간과 식별자로 연결 불가(참고용).Suggested fix (제안)
startedAt/endedAt, 스트리밍index등)를 해시에서 제외하거나, message_end의 후처리된 메시지 대신 SDK 세션에 실제로 보낸 델타/flatten 페이로드를 지문으로 사용.captureProviderFinal이 마지막 stream mutation 이후(예: assistant 메시지 최종본)를 잡도록 순서 조정. 단 프로젝션 경로상 원본/클론 불일치가 있으므로 1안이 더 견고.captureProviderFinal은 이미 digest 문자열을 즉시 저장하므로 "capture 시 딥카피"는 이 문제를 해결하지 못함 — 지문 정의 변경이 필요.Evidence / logs
~/.senpi/agent/logs/session.log— continuity 130건 (모든 수치의 원천).overrideSdkBoundary(Tier 1, 0토큰) / 실 SDK(Tier 2) ×AssistantCommitBoundary.prototype계측. 원하시면 스크립트 전체를 공유하겠습니다.2026.8.3-3,@earendil-works/pi-agent-core@2026.8.3-3(npm:@code-yeongyu/senpi-agent-core@2026.8.3-3).Env
2026.8.3-3(npm latest)claude-sdk-oauth(실 SDK 테스트:claude-sonnet-5)