Skip to content

Commit 428b07d

Browse files
committed
fix(parser): force full parse when a queued command masks the split check
The engine's cross-sync split detection compares only the first appended message's ClaudeMessageID against the stored tail. Queued-command attachments are written mid-stream between same-message.id assistant chunks, so one falling inside a run that straddles a sync boundary sorts ahead of the continuation head and hides it, appending the stored partial response a second time. Detect the masking in claudeParseSessionFrom and fall back to a replacing full parse.
1 parent ef93382 commit 428b07d

3 files changed

Lines changed: 191 additions & 2 deletions

File tree

docs/internal/session-format-sources.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,12 @@ Grok section and remove the explicit registry exception in the coverage test.
7070
cost field is consumed; Agentsview prices the tokens from its catalog.
7171
- **Agentsview:** `internal/parser/claude.go` and
7272
`internal/parser/claude_provider.go`; local observations and fixtures are
73-
the implementation evidence for fields not documented upstream.
73+
the implementation evidence for fields not documented upstream. Reverified
74+
2026-07-22 against local CLI transcripts: `type=attachment` records with
75+
`attachment.type=queued_command` are written mid-stream, in file order
76+
between consecutive `assistant` records that share one `message.id`, so a
77+
queued command can fall inside a streaming run that straddles an incremental
78+
sync boundary.
7479

7580
## OpenClaude (`openclaude`)
7681

internal/parser/claude.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,9 @@ func claudeParseSessionFrom(
695695
// happens in the full parser's post-merge uuid set, driving such
696696
// files to linear parsing. Runs that straddle a sync boundary are
697697
// detected by the engine's LastClaudeMessageID check on the first
698-
// appended assistant message.
698+
// appended assistant message; when a queued command would sort
699+
// ahead of that head and mask the check, the parser itself falls
700+
// back to a full parse (claudeQueuedCommandMasksSplitDetection).
699701
if len(entries) > 1 {
700702
entries = mergeClaudeAssistantMessageChunks(entries)
701703
}
@@ -763,6 +765,16 @@ func claudeParseSessionFrom(
763765
)
764766
annotateSubagentSessions(msgs, subagentMap)
765767
if len(queuedCommands) > 0 {
768+
// The engine's cross-sync split detection compares only the
769+
// FIRST returned message's ClaudeMessageID against the stored
770+
// tail. A queued command sorting ahead of an assistant head
771+
// would mask a same-message.id continuation and let the stored
772+
// partial response be appended a second time instead of
773+
// replaced — fall back to a full parse instead.
774+
if claudeQueuedCommandMasksSplitDetection(msgs, queuedCommands) {
775+
return nil, nil, time.Time{}, 0,
776+
ErrClaudeIncrementalNeedsFullParse
777+
}
766778
msgs = mergeQueuedCommands(
767779
msgs, queuedCommands, startOrdinal, queuedCommandMessage,
768780
)
@@ -1487,6 +1499,33 @@ func mergeQueuedCommands(
14871499
return out
14881500
}
14891501

1502+
// claudeQueuedCommandMasksSplitDetection reports whether merging
1503+
// queued commands would sort one ahead of a leading assistant message
1504+
// that carries a provider message id. The engine's cross-sync split
1505+
// detection (LastClaudeMessageID) inspects only the first appended
1506+
// message, so a displaced assistant head would hide a same-message.id
1507+
// continuation of the stored tail and duplicate the partial response.
1508+
// Real CLI transcripts write queued_command attachments mid-stream,
1509+
// between chunks of one response, so this masking is reachable
1510+
// whenever the sync boundary falls inside such a run.
1511+
func claudeQueuedCommandMasksSplitDetection(
1512+
msgs []ParsedMessage, queued []claudeQueuedCommand,
1513+
) bool {
1514+
if len(msgs) == 0 {
1515+
return false
1516+
}
1517+
head := msgs[0]
1518+
if head.Role != RoleAssistant || head.ClaudeMessageID == "" {
1519+
return false
1520+
}
1521+
for _, qc := range queued {
1522+
if queuedBefore(qc, head) {
1523+
return true
1524+
}
1525+
}
1526+
return false
1527+
}
1528+
14901529
// queuedBefore reports whether a queued_command should sort before
14911530
// a regular message. Zero timestamps on either side are treated
14921531
// conservatively: a zero-timestamp message keeps its original

internal/parser/claude_parser_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,151 @@ func TestParseClaudeSessionFrom_SameMessageIDRunMergesIncrementally(
14861486
assert.Equal(t, RoleUser, newMsgs[1].Role)
14871487
}
14881488

1489+
// A queued_command attachment can be written mid-stream, between
1490+
// assistant chunks of one same-message.id response (observed in real
1491+
// CLI transcripts). When the sync boundary falls inside that run, the
1492+
// append window is [queued command, chunk, chunk] and the queued
1493+
// command's earlier timestamp sorts it to position 0 of the returned
1494+
// messages. The engine's cross-sync split detection compares only the
1495+
// FIRST appended message's ClaudeMessageID against the stored tail, so
1496+
// a masked head would append the continuation as a duplicate message.
1497+
// The parser must fall back to a full parse instead.
1498+
func TestParseClaudeSessionFrom_QueuedCommandBeforeContinuationFallsBack(
1499+
t *testing.T,
1500+
) {
1501+
t.Parallel()
1502+
1503+
chunk := func(uuid, parent, ts, text string) string {
1504+
return `{"type":"assistant","uuid":"` + uuid +
1505+
`","parentUuid":"` + parent +
1506+
`","timestamp":"` + ts +
1507+
`","message":{"id":"msg_split","content":[` +
1508+
`{"type":"text","text":"` + text + `"}]}}`
1509+
}
1510+
1511+
initial := testjsonl.JoinJSONL(
1512+
testjsonl.ClaudeUserJSON("hello", tsEarly),
1513+
chunk("a1", "u1", "2024-01-01T10:00:01Z", "Hel"),
1514+
)
1515+
1516+
parseFrom := func(
1517+
t *testing.T, appended string,
1518+
) ([]ParsedMessage, error) {
1519+
t.Helper()
1520+
path := createTestFile(
1521+
t, "inc-queued-boundary.jsonl", initial,
1522+
)
1523+
info, err := os.Stat(path)
1524+
require.NoError(t, err)
1525+
offset := info.Size()
1526+
1527+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
1528+
require.NoError(t, err)
1529+
_, err = f.WriteString(appended)
1530+
require.NoError(t, err)
1531+
require.NoError(t, f.Close())
1532+
1533+
msgs, _, _, _, perr := claudeParseSessionFrom(
1534+
path, offset, claudeIncrementalScan{
1535+
startOrdinal: 2,
1536+
lastEntryUUID: "a1",
1537+
storedLinearParse: new(true),
1538+
},
1539+
)
1540+
return msgs, perr
1541+
}
1542+
1543+
t.Run("queued command masks continuation head", func(t *testing.T) {
1544+
t.Parallel()
1545+
1546+
appended := testjsonl.ClaudeQueuedCommandJSON(
1547+
"queued mid-stream", "2024-01-01T10:00:02Z",
1548+
) + "\n" +
1549+
chunk("a2", "a1", "2024-01-01T10:00:03Z", "Hello wo") + "\n" +
1550+
chunk("a3", "a2", "2024-01-01T10:00:04Z", "Hello world") + "\n"
1551+
1552+
_, perr := parseFrom(t, appended)
1553+
assert.ErrorIs(t, perr, ErrClaudeIncrementalNeedsFullParse,
1554+
"a queued command sorting ahead of a same-message.id "+
1555+
"continuation must force a full parse")
1556+
})
1557+
1558+
t.Run("full parse of the whole file keeps one merged message",
1559+
func(t *testing.T) {
1560+
t.Parallel()
1561+
1562+
content := initial + "\n" + testjsonl.JoinJSONL(
1563+
testjsonl.ClaudeQueuedCommandJSON(
1564+
"queued mid-stream", "2024-01-01T10:00:02Z",
1565+
),
1566+
chunk("a2", "a1", "2024-01-01T10:00:03Z", "Hello wo"),
1567+
chunk("a3", "a2", "2024-01-01T10:00:04Z", "Hello world"),
1568+
)
1569+
_, msgs := runClaudeParserTest(
1570+
t, "queued-boundary-full.jsonl", content,
1571+
)
1572+
require.Len(t, msgs, 3)
1573+
assert.Equal(t, RoleUser, msgs[0].Role)
1574+
assert.Equal(t, "queued_command", msgs[1].SourceSubtype)
1575+
assert.Equal(t, RoleAssistant, msgs[2].Role)
1576+
assert.Equal(t, "Hello world", msgs[2].Content,
1577+
"the run must collapse to one merged assistant message")
1578+
ids := 0
1579+
for _, m := range msgs {
1580+
if m.ClaudeMessageID == "msg_split" {
1581+
ids++
1582+
}
1583+
}
1584+
assert.Equal(t, 1, ids,
1585+
"msg_split must appear exactly once after a full parse")
1586+
})
1587+
1588+
t.Run("queued command after the run stays incremental",
1589+
func(t *testing.T) {
1590+
t.Parallel()
1591+
1592+
appended := chunk(
1593+
"a2", "a1", "2024-01-01T10:00:03Z", "Hello wo",
1594+
) + "\n" +
1595+
chunk("a3", "a2", "2024-01-01T10:00:04Z", "Hello world") +
1596+
"\n" + testjsonl.ClaudeQueuedCommandJSON(
1597+
"queued after", "2024-01-01T10:02:00Z",
1598+
) + "\n"
1599+
1600+
msgs, perr := parseFrom(t, appended)
1601+
require.NoError(t, perr)
1602+
require.Len(t, msgs, 2)
1603+
assert.Equal(t, RoleAssistant, msgs[0].Role)
1604+
assert.Equal(t, "msg_split", msgs[0].ClaudeMessageID,
1605+
"the continuation head must stay first so the engine's "+
1606+
"split check can see it")
1607+
assert.Equal(t, "queued_command", msgs[1].SourceSubtype)
1608+
})
1609+
1610+
t.Run("queued command before a user head stays incremental",
1611+
func(t *testing.T) {
1612+
t.Parallel()
1613+
1614+
appended := testjsonl.ClaudeQueuedCommandJSON(
1615+
"queued early", "2024-01-01T10:00:02Z",
1616+
) + "\n" +
1617+
`{"type":"user","uuid":"u2","parentUuid":"a1",` +
1618+
`"timestamp":"2024-01-01T10:00:03Z",` +
1619+
`"message":{"content":"next turn"}}` + "\n" +
1620+
`{"type":"assistant","uuid":"a2","parentUuid":"u2",` +
1621+
`"timestamp":"2024-01-01T10:00:04Z",` +
1622+
`"message":{"id":"msg_next","content":[` +
1623+
`{"type":"text","text":"fresh"}]}}` + "\n"
1624+
1625+
msgs, perr := parseFrom(t, appended)
1626+
require.NoError(t, perr)
1627+
require.Len(t, msgs, 3)
1628+
assert.Equal(t, "queued_command", msgs[0].SourceSubtype)
1629+
assert.Equal(t, "next turn", msgs[1].Content)
1630+
assert.Equal(t, "msg_next", msgs[2].ClaudeMessageID)
1631+
})
1632+
}
1633+
14891634
// An appended parentless entry adds a DAG root — the only property of
14901635
// the stored linearity verdict an append can move toward
14911636
// resolvability — so even a linear-bound session must fall back to a

0 commit comments

Comments
 (0)