Skip to content

claude-sdk-oauth: thinking-block timing fields flip commit-boundary hash → assistant_rewritten nearly every turn → repeated history re-send #691

Description

@JangHyuckYun

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 사이에 달라집니다.

코드 경로:

  1. pi-agent-coreagent-loop.js streamAssistantResponse()는 스트리밍 중 thinking 이벤트에서 block.startedAt = timing.startedAt; block.endedAt = timing.endedAt을 설정한 뒤 message_update를 발행하고, 턴 종료(done) 시 propagateThinkingTiming(finalMessage)또 한 번 startedAt/endedAt을 타이밍 맵 값으로 재설정한다.
  2. @earendil-works/pi-aitool-call-middleware(recovery-stream-wrapperStreamThinkingProjection, preserveSourceMetadata: true)는 thinking 이벤트를 프로젝션된 별도 메시지로 내보낸다. cloneThinking(){...block, thinking}을 만들어 프로젝션 메시지에 넣으므로, agent-loop가 타이밍 필드를 붙이는 thinking 블록은 원본 output의 블록과 다른 객체다. text 이벤트가 내보낼 때는 원본 경로가 쓰인다.
  3. 결과적으로 마지막 message_update가 관찰하는 contentmessage_end가 관찰하는 content(finally에서 propagateThinkingTiming이 적용된 원본 output) 의 thinking 블록에 startedAt/endedAt 유무가 갈라진다.
  4. session-commit-boundary.jsassistantContentHashcontent 전체(sessionSyncDigest)를 해시하므로 이 메타데이터 차이가 해시를 바꾼다 → commit()"rewritten" 반환.
  5. session-registry-wiring.jsmessage_end 핸들러가 recordPendingFork(sessionId, "assistant_rewritten") 기록.
  6. 다음 턴 session-continuity.js decideNativeContinuity()pendingForkReason을 보고 forkOrFlatten; boundaryBeforesentCount 이전 assistant uuid를 못 찾거나 먼 경계를 잡으면 flatten/fork.
  7. flatten은 새 SDK 세션을 열고 buildPromptBlocks로 전체 대화를 재주입. recordSyncedStreamsentCount = 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로 실제 파이프라인(createAgentSessionprompt ×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:04Z03:08:29Z 2 → 62
flatten 37건 01:11:03Z01:45:09Z 8 → 93
flatten 20건 01:45:47Z01:50:56Z 96 → 125
flatten 3건 (피크) 02:07:05Z02: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 (제안)

  1. 커밋 경계 지문을 '안정적인 페이로드'로 정의 — 휘발성 표시/텔레메트리 필드(startedAt/endedAt, 스트리밍 index 등)를 해시에서 제외하거나, message_end의 후처리된 메시지 대신 SDK 세션에 실제로 보낸 델타/flatten 페이로드를 지문으로 사용.
  2. capture 시점을 후처리 이후로 이동captureProviderFinal이 마지막 stream mutation 이후(예: assistant 메시지 최종본)를 잡도록 순서 조정. 단 프로젝션 경로상 원본/클론 불일치가 있으므로 1안이 더 견고.
  3. 리뷰에서 확인된 사실: 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions