Skip to content

Commit 5974ba9

Browse files
peyton-altclaude
andcommitted
fix(review): capture Claude reviewer model via transcript backfill (#1373)
Claude review subprocesses run via `claude -p` don't reliably report a model through lifecycle hooks (the ModelUpdate path is flaky in headless mode), so per-session checkpoint metadata landed with "model": "" for Claude reviewer sessions, while codex/pi reviewers record theirs. Root cause: ClaudeCodeAgent did not implement agent.ModelExtractor, so condense's sessionStateBackfillModel fallback (AsModelExtractor -> ExtractModel) was a no-op for Claude; the model relied solely on the flaky hook path. Implement ExtractModel on ClaudeCodeAgent: parse the transcript and return the last non-empty model from an assistant line (mirrors pi/copilotcli/factoryai). Condense already calls this when state.ModelName == "", so review (and any hook-missed) Claude sessions now record a non-empty model deterministically. Verified against real recorded data: a real agent_review row in entire/checkpoints/v1 with model="" (skills=[/review]) — running the new ExtractModel on that session's stored transcript recovers "claude-opus-4-7", which is exactly the value the condense backfill writes. Surfaces in entire.io's per-reviewer attribution (entirehq/entire.io#2271). Note: the transcript carries the base model id ("claude-opus-4-7"), not the hook's context-window variant ("claude-opus-4-7[1m]"), so backfilled models omit the [1m] suffix — a correct, non-empty id, slightly less precise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 41d9aa9acef6
1 parent 69cc486 commit 5974ba9

2 files changed

Lines changed: 71 additions & 0 deletions

File tree

cmd/entire/cli/agent/claudecode/transcript.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,35 @@ func ExtractModifiedFiles(lines []TranscriptLine) []string {
8888
return files
8989
}
9090

91+
// ExtractModel returns the LLM model recorded in the transcript, satisfying
92+
// agent.ModelExtractor. Claude review subprocesses spawned via `claude -p` do
93+
// not reliably report the model through lifecycle hooks (the ModelUpdate path
94+
// is flaky in headless mode), so condense backfills it from the transcript
95+
// here — the same mechanism codex/pi/etc. rely on. Returns the last non-empty
96+
// model seen on an assistant line, or "" when the transcript carries none.
97+
func (c *ClaudeCodeAgent) ExtractModel(transcriptData []byte) (string, error) {
98+
lines, err := transcript.ParseFromBytes(transcriptData)
99+
if err != nil {
100+
return "", fmt.Errorf("parse transcript: %w", err)
101+
}
102+
model := ""
103+
for _, line := range lines {
104+
if line.Type != envelopeTypeAssistant {
105+
continue
106+
}
107+
var msg struct {
108+
Model string `json:"model"`
109+
}
110+
if err := json.Unmarshal(line.Message, &msg); err != nil {
111+
continue
112+
}
113+
if msg.Model != "" {
114+
model = msg.Model
115+
}
116+
}
117+
return model, nil
118+
}
119+
91120
// TruncateAtUUID returns transcript lines up to and including the line with given UUID
92121
func TruncateAtUUID(lines []TranscriptLine, uuid string) []TranscriptLine {
93122
if uuid == "" {

cmd/entire/cli/agent/claudecode/transcript_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,3 +869,45 @@ func TestExtractAllModifiedFiles_SubagentOnlyChanges(t *testing.T) {
869869
t.Errorf("missing expected file %q", f)
870870
}
871871
}
872+
873+
func TestExtractModel(t *testing.T) {
874+
t.Parallel()
875+
data := []byte(`{"type":"assistant","uuid":"a1","message":{"model":"claude-opus-4-7[1m]","content":[{"type":"text","text":"hi"}]}}` + "\n")
876+
model, err := (&ClaudeCodeAgent{}).ExtractModel(data)
877+
if err != nil {
878+
t.Fatal(err)
879+
}
880+
if model != "claude-opus-4-7[1m]" {
881+
t.Errorf("model = %q, want claude-opus-4-7[1m]", model)
882+
}
883+
}
884+
885+
func TestExtractModel_MostRecentWins(t *testing.T) {
886+
t.Parallel()
887+
data := []byte(strings.Join([]string{
888+
`{"type":"assistant","uuid":"a1","message":{"model":"claude-sonnet-4-6","content":[]}}`,
889+
`{"type":"assistant","uuid":"a2","message":{"model":"claude-opus-4-8","content":[]}}`,
890+
"",
891+
}, "\n"))
892+
model, err := (&ClaudeCodeAgent{}).ExtractModel(data)
893+
if err != nil {
894+
t.Fatal(err)
895+
}
896+
if model != "claude-opus-4-8" {
897+
t.Errorf("model = %q, want claude-opus-4-8 (most recent)", model)
898+
}
899+
}
900+
901+
func TestExtractModel_EmptyWhenAbsent(t *testing.T) {
902+
t.Parallel()
903+
// A review subprocess transcript with no model field on any assistant line
904+
// yields "" (caller treats that as "no model available").
905+
data := []byte(`{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"hi"}]}}` + "\n")
906+
model, err := (&ClaudeCodeAgent{}).ExtractModel(data)
907+
if err != nil {
908+
t.Fatal(err)
909+
}
910+
if model != "" {
911+
t.Errorf("model = %q, want empty", model)
912+
}
913+
}

0 commit comments

Comments
 (0)