Skip to content

Commit b91c823

Browse files
adammwmjacobs
authored andcommitted
fix(claude): keep IDE-context envelopes out of first_message previews
The VS Code extension often prepends a <ide_opened_file> or <ide_selection> wrapper directly onto a real prompt in the same user entry. PR kenn-io#1252 promoted standalone envelopes to hidden system metadata, but this mixed case (envelope + real prompt in one message) falls outside that strict standalone match by design, so the raw markup stayed in first_message and the visible transcript. Split a leading IDE-context envelope off from the rest of the message: the envelope becomes its own hidden system-metadata message (same ide_opened_file/ide_selection subtype as the standalone case), and the remaining real prompt becomes an ordinary user message. first_message and user turn counts are computed from the same firstMessageAndUserCount pass used before, so they now derive from the real prompt only. Bumps the parser data version to 75 so existing rows re-parse.
1 parent 5bc9617 commit b91c823

4 files changed

Lines changed: 181 additions & 5 deletions

File tree

internal/db/db.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,12 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
361361
// last_activity_at, the Devin fingerprint hashes only raw epoch integers and
362362
// zero-time metadata, so it is byte-identical before and after the fix and
363363
// incremental sync would skip the correction.)
364-
const dataVersion = 78
364+
// (79: Claude Code IDE context wrappers prepended onto a real prompt in
365+
// the same entry are now split into a hidden system-metadata message plus
366+
// the real prompt, instead of leaving the raw wrapper in first_message and
367+
// the visible transcript. Existing rows need re-parsing so first_message
368+
// and message content drop the leading markup.)
369+
const dataVersion = 79
365370

366371
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
367372

internal/db/db_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,10 +1008,9 @@ func TestMigration_ToolResultEventsTable(t *testing.T) {
10081008
"expected tool_result_events table after reopen")
10091009
}
10101010

1011-
func TestCurrentDataVersionDevinEpochSecondsReparse(t *testing.T) {
1012-
assert.Equal(t, 78, CurrentDataVersion(),
1013-
"version 78 reparses Devin sessions whose timestamps were read as "+
1014-
"milliseconds and stored as 1970 dates")
1011+
func TestCurrentDataVersionClaudeIDEEnvelopeSplit(t *testing.T) {
1012+
assert.Equal(t, 79, CurrentDataVersion(),
1013+
"version 79 splits Claude IDE envelopes off mixed prompts")
10151014
}
10161015

10171016
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/claude.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,29 @@ func extractMessagesFrom(
10931093
ordinal++
10941094
continue
10951095
}
1096+
// The VS Code extension sometimes prepends an IDE-context
1097+
// wrapper directly onto a real prompt in the same entry.
1098+
// Split it into a hidden system-metadata message plus the
1099+
// real prompt, so first_message and the visible transcript
1100+
// show only the prompt.
1101+
if subtype, envelope, remainder, ok :=
1102+
splitLeadingClaudeIDEEnvelope(text); ok {
1103+
messages = append(messages, ParsedMessage{
1104+
Ordinal: ordinal,
1105+
Role: RoleUser,
1106+
Content: envelope,
1107+
Timestamp: e.timestamp,
1108+
IsSystem: true,
1109+
ContentLength: len(envelope),
1110+
SourceType: "system",
1111+
SourceSubtype: subtype,
1112+
SourceUUID: e.uuid,
1113+
SourceParentUUID: e.parentUuid,
1114+
IsSidechain: gjson.Get(e.line, "isSidechain").Bool(),
1115+
})
1116+
ordinal++
1117+
text = remainder
1118+
}
10961119
// Skip unclassified noise (e.g. non-caveat
10971120
// <local-command-*> envelopes).
10981121
if isClaudeSystemMessage(text) {
@@ -2125,6 +2148,29 @@ func extractMessages(entries []dagEntry) (
21252148
ordinal++
21262149
continue
21272150
}
2151+
// The VS Code extension sometimes prepends an IDE-context
2152+
// wrapper directly onto a real prompt in the same entry.
2153+
// Split it into a hidden system-metadata message plus the
2154+
// real prompt, so first_message and the visible transcript
2155+
// show only the prompt.
2156+
if subtype, envelope, remainder, ok :=
2157+
splitLeadingClaudeIDEEnvelope(text); ok {
2158+
messages = append(messages, ParsedMessage{
2159+
Ordinal: ordinal,
2160+
Role: RoleUser,
2161+
Content: envelope,
2162+
Timestamp: e.timestamp,
2163+
IsSystem: true,
2164+
ContentLength: len(envelope),
2165+
SourceType: "system",
2166+
SourceSubtype: subtype,
2167+
SourceUUID: e.uuid,
2168+
SourceParentUUID: e.parentUuid,
2169+
IsSidechain: gjson.Get(e.line, "isSidechain").Bool(),
2170+
})
2171+
ordinal++
2172+
text = remainder
2173+
}
21282174
if isClaudeSystemMessage(text) {
21292175
continue
21302176
}
@@ -2611,6 +2657,50 @@ func isStandaloneClaudeTaggedMessage(content, tag string) bool {
26112657
len(afterOpen)-len(closeTag)
26122658
}
26132659

2660+
// claudeIDEEnvelopeTags are the VS Code extension's IDE-context
2661+
// wrapper tags: standalone messages using these are already
2662+
// promoted to hidden system metadata by classifyClaudeSystemMessage.
2663+
// splitLeadingClaudeIDEEnvelope handles the remaining case where the
2664+
// extension prepends one of these wrappers directly onto a real
2665+
// prompt in the same user entry.
2666+
var claudeIDEEnvelopeTags = [...]string{"ide_opened_file", "ide_selection"}
2667+
2668+
// splitLeadingClaudeIDEEnvelope detects a well-formed IDE-context
2669+
// envelope at the very start of content that is followed by
2670+
// additional real prompt text, and separates the two. The standalone
2671+
// case (envelope with nothing else) is left alone here; that is
2672+
// handled by classifyClaudeSystemMessage so the whole message
2673+
// promotes to system metadata.
2674+
//
2675+
// Splitting keeps the envelope recorded as hidden system metadata
2676+
// (same subtype as the standalone case) while letting first_message
2677+
// and the visible transcript show only the real prompt that follows,
2678+
// instead of raw IDE-context markup.
2679+
func splitLeadingClaudeIDEEnvelope(
2680+
content string,
2681+
) (subtype, envelope, remainder string, ok bool) {
2682+
trimmed := trimClaudeSystemMessagePrefix(content)
2683+
for _, tag := range claudeIDEEnvelopeTags {
2684+
openTag := "<" + tag + ">"
2685+
closeTag := "</" + tag + ">"
2686+
if !strings.HasPrefix(trimmed, openTag) {
2687+
continue
2688+
}
2689+
closeIdx := strings.Index(trimmed, closeTag)
2690+
if closeIdx < 0 {
2691+
continue
2692+
}
2693+
envelopeEnd := closeIdx + len(closeTag)
2694+
rest := strings.TrimSpace(trimmed[envelopeEnd:])
2695+
if rest == "" {
2696+
// Standalone: classifyClaudeSystemMessage handles this.
2697+
continue
2698+
}
2699+
return tag, trimmed[:envelopeEnd], rest, true
2700+
}
2701+
return "", "", "", false
2702+
}
2703+
26142704
func stripLeadingClaudeSystemReminderContent(content string) string {
26152705
trimmed := trimClaudeSystemMessagePrefix(content)
26162706
remainder, stripped := stripLeadingClaudeSystemReminderBlocks(trimmed)

internal/parser/claude_parser_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,48 @@ func TestParseClaudeSession_SkippedMessages(t *testing.T) {
324324
assert.Equal(t, "real user message", msgs[7].Content)
325325
})
326326

327+
t.Run("splits IDE envelope prepended onto a real prompt", func(t *testing.T) {
328+
content := testjsonl.JoinJSONL(
329+
testjsonl.ClaudeUserJSON(
330+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file> Explain this file.",
331+
tsZero,
332+
),
333+
testjsonl.ClaudeUserJSON(
334+
"<ide_selection>The user selected package main.</ide_selection>\n\nWhat does this do?",
335+
tsZeroS1,
336+
),
337+
)
338+
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
339+
// Each entry splits into a hidden system-metadata message
340+
// plus the real prompt that followed it.
341+
require.Len(t, msgs, 4)
342+
assert.Equal(t, 4, sess.MessageCount)
343+
assert.Equal(t, 2, sess.UserMessageCount)
344+
assert.Equal(t, "Explain this file.", sess.FirstMessage,
345+
"first_message should show the real prompt, not the IDE envelope")
346+
347+
assert.True(t, msgs[0].IsSystem)
348+
assert.Equal(t, RoleUser, msgs[0].Role)
349+
assert.Equal(t, "system", msgs[0].SourceType)
350+
assert.Equal(t, "ide_opened_file", msgs[0].SourceSubtype)
351+
assert.Equal(t,
352+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file>",
353+
msgs[0].Content)
354+
355+
assert.False(t, msgs[1].IsSystem)
356+
assert.Equal(t, RoleUser, msgs[1].Role)
357+
assert.Equal(t, "Explain this file.", msgs[1].Content)
358+
359+
assert.True(t, msgs[2].IsSystem)
360+
assert.Equal(t, "ide_selection", msgs[2].SourceSubtype)
361+
assert.Equal(t,
362+
"<ide_selection>The user selected package main.</ide_selection>",
363+
msgs[2].Content)
364+
365+
assert.False(t, msgs[3].IsSystem)
366+
assert.Equal(t, "What does this do?", msgs[3].Content)
367+
})
368+
327369
t.Run("skill invocation shown as user message", func(t *testing.T) {
328370
content := testjsonl.JoinJSONL(
329371
testjsonl.ClaudeUserJSON(
@@ -869,6 +911,46 @@ func TestParseClaudeSessionFrom_IDEContext(t *testing.T) {
869911
}
870912
}
871913

914+
func TestParseClaudeSessionFrom_IDEContextPrependedToPrompt(t *testing.T) {
915+
t.Parallel()
916+
917+
initial := testjsonl.JoinJSONL(
918+
testjsonl.ClaudeUserJSON("hello", tsEarly),
919+
testjsonl.ClaudeAssistantJSON("hi", tsEarlyS1),
920+
)
921+
path := createTestFile(t, "inc-ide-context-prompt.jsonl", initial)
922+
info, err := os.Stat(path)
923+
require.NoError(t, err)
924+
925+
appended := testjsonl.JoinJSONL(
926+
testjsonl.ClaudeUserJSON(
927+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file> Explain this file.",
928+
tsLate,
929+
),
930+
)
931+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
932+
require.NoError(t, err)
933+
_, err = f.WriteString(appended)
934+
require.NoError(t, err)
935+
require.NoError(t, f.Close())
936+
937+
newMsgs, _, _, err := callParseClaudeSessionFrom(path, info.Size(), 2, "")
938+
require.NoError(t, err)
939+
require.Len(t, newMsgs, 2,
940+
"the entry splits into a hidden IDE-context message plus the real prompt")
941+
942+
assert.True(t, newMsgs[0].IsSystem)
943+
assert.Equal(t, "system", newMsgs[0].SourceType)
944+
assert.Equal(t, "ide_opened_file", newMsgs[0].SourceSubtype)
945+
assert.Equal(t,
946+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file>",
947+
newMsgs[0].Content)
948+
949+
assert.False(t, newMsgs[1].IsSystem)
950+
assert.Equal(t, RoleUser, newMsgs[1].Role)
951+
assert.Equal(t, "Explain this file.", newMsgs[1].Content)
952+
}
953+
872954
func TestParseClaudeSessionFrom_ReminderPrefixedCommand(t *testing.T) {
873955
t.Parallel()
874956

0 commit comments

Comments
 (0)