Skip to content

Commit 6d273a3

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 ecd0eba commit 6d273a3

4 files changed

Lines changed: 182 additions & 4 deletions

File tree

internal/db/db.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,12 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
364364
// (79: Claude launch/prompt provenance. Re-parsing populates the new
365365
// sessions.session_kind and messages.prompt_source columns from top-level
366366
// sessionKind and promptSource fields on existing Claude rows.)
367-
const dataVersion = 79
367+
// (80: Claude Code IDE context wrappers prepended onto a real prompt in
368+
// the same entry are now split into a hidden system-metadata message plus
369+
// the real prompt, instead of leaving the raw wrapper in first_message and
370+
// the visible transcript. Existing rows need re-parsing so first_message
371+
// and message content drop the leading markup.)
372+
const dataVersion = 80
368373

369374
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
370375

internal/db/db_test.go

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

1011-
func TestCurrentDataVersionClaudeLaunchProvenance(t *testing.T) {
1012-
assert.Equal(t, 79, CurrentDataVersion(),
1013-
"session_kind/prompt_source backfill requires a data version bump")
1011+
func TestCurrentDataVersionClaudeIDEEnvelopeSplit(t *testing.T) {
1012+
assert.Equal(t, 80, CurrentDataVersion(),
1013+
"version 80 splits Claude IDE envelopes off mixed prompts after "+
1014+
"the Claude launch provenance reparse")
10141015
}
10151016

10161017
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/claude.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,29 @@ func extractMessagesFrom(
11081108
ordinal++
11091109
continue
11101110
}
1111+
// The VS Code extension sometimes prepends an IDE-context
1112+
// wrapper directly onto a real prompt in the same entry.
1113+
// Split it into a hidden system-metadata message plus the
1114+
// real prompt, so first_message and the visible transcript
1115+
// show only the prompt.
1116+
if subtype, envelope, remainder, ok :=
1117+
splitLeadingClaudeIDEEnvelope(text); ok {
1118+
messages = append(messages, ParsedMessage{
1119+
Ordinal: ordinal,
1120+
Role: RoleUser,
1121+
Content: envelope,
1122+
Timestamp: e.timestamp,
1123+
IsSystem: true,
1124+
ContentLength: len(envelope),
1125+
SourceType: "system",
1126+
SourceSubtype: subtype,
1127+
SourceUUID: e.uuid,
1128+
SourceParentUUID: e.parentUuid,
1129+
IsSidechain: gjson.Get(e.line, "isSidechain").Bool(),
1130+
})
1131+
ordinal++
1132+
text = remainder
1133+
}
11111134
// Skip unclassified noise (e.g. non-caveat
11121135
// <local-command-*> envelopes).
11131136
if isClaudeSystemMessage(text) {
@@ -2148,6 +2171,29 @@ func extractMessages(entries []dagEntry) (
21482171
ordinal++
21492172
continue
21502173
}
2174+
// The VS Code extension sometimes prepends an IDE-context
2175+
// wrapper directly onto a real prompt in the same entry.
2176+
// Split it into a hidden system-metadata message plus the
2177+
// real prompt, so first_message and the visible transcript
2178+
// show only the prompt.
2179+
if subtype, envelope, remainder, ok :=
2180+
splitLeadingClaudeIDEEnvelope(text); ok {
2181+
messages = append(messages, ParsedMessage{
2182+
Ordinal: ordinal,
2183+
Role: RoleUser,
2184+
Content: envelope,
2185+
Timestamp: e.timestamp,
2186+
IsSystem: true,
2187+
ContentLength: len(envelope),
2188+
SourceType: "system",
2189+
SourceSubtype: subtype,
2190+
SourceUUID: e.uuid,
2191+
SourceParentUUID: e.parentUuid,
2192+
IsSidechain: gjson.Get(e.line, "isSidechain").Bool(),
2193+
})
2194+
ordinal++
2195+
text = remainder
2196+
}
21512197
if isClaudeSystemMessage(text) {
21522198
continue
21532199
}
@@ -2635,6 +2681,50 @@ func isStandaloneClaudeTaggedMessage(content, tag string) bool {
26352681
len(afterOpen)-len(closeTag)
26362682
}
26372683

2684+
// claudeIDEEnvelopeTags are the VS Code extension's IDE-context
2685+
// wrapper tags: standalone messages using these are already
2686+
// promoted to hidden system metadata by classifyClaudeSystemMessage.
2687+
// splitLeadingClaudeIDEEnvelope handles the remaining case where the
2688+
// extension prepends one of these wrappers directly onto a real
2689+
// prompt in the same user entry.
2690+
var claudeIDEEnvelopeTags = [...]string{"ide_opened_file", "ide_selection"}
2691+
2692+
// splitLeadingClaudeIDEEnvelope detects a well-formed IDE-context
2693+
// envelope at the very start of content that is followed by
2694+
// additional real prompt text, and separates the two. The standalone
2695+
// case (envelope with nothing else) is left alone here; that is
2696+
// handled by classifyClaudeSystemMessage so the whole message
2697+
// promotes to system metadata.
2698+
//
2699+
// Splitting keeps the envelope recorded as hidden system metadata
2700+
// (same subtype as the standalone case) while letting first_message
2701+
// and the visible transcript show only the real prompt that follows,
2702+
// instead of raw IDE-context markup.
2703+
func splitLeadingClaudeIDEEnvelope(
2704+
content string,
2705+
) (subtype, envelope, remainder string, ok bool) {
2706+
trimmed := trimClaudeSystemMessagePrefix(content)
2707+
for _, tag := range claudeIDEEnvelopeTags {
2708+
openTag := "<" + tag + ">"
2709+
closeTag := "</" + tag + ">"
2710+
if !strings.HasPrefix(trimmed, openTag) {
2711+
continue
2712+
}
2713+
closeIdx := strings.Index(trimmed, closeTag)
2714+
if closeIdx < 0 {
2715+
continue
2716+
}
2717+
envelopeEnd := closeIdx + len(closeTag)
2718+
rest := strings.TrimSpace(trimmed[envelopeEnd:])
2719+
if rest == "" {
2720+
// Standalone: classifyClaudeSystemMessage handles this.
2721+
continue
2722+
}
2723+
return tag, trimmed[:envelopeEnd], rest, true
2724+
}
2725+
return "", "", "", false
2726+
}
2727+
26382728
func stripLeadingClaudeSystemReminderContent(content string) string {
26392729
trimmed := trimClaudeSystemMessagePrefix(content)
26402730
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(
@@ -886,6 +928,46 @@ func TestParseClaudeSessionFrom_IDEContext(t *testing.T) {
886928
}
887929
}
888930

931+
func TestParseClaudeSessionFrom_IDEContextPrependedToPrompt(t *testing.T) {
932+
t.Parallel()
933+
934+
initial := testjsonl.JoinJSONL(
935+
testjsonl.ClaudeUserJSON("hello", tsEarly),
936+
testjsonl.ClaudeAssistantJSON("hi", tsEarlyS1),
937+
)
938+
path := createTestFile(t, "inc-ide-context-prompt.jsonl", initial)
939+
info, err := os.Stat(path)
940+
require.NoError(t, err)
941+
942+
appended := testjsonl.JoinJSONL(
943+
testjsonl.ClaudeUserJSON(
944+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file> Explain this file.",
945+
tsLate,
946+
),
947+
)
948+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
949+
require.NoError(t, err)
950+
_, err = f.WriteString(appended)
951+
require.NoError(t, err)
952+
require.NoError(t, f.Close())
953+
954+
newMsgs, _, _, err := callParseClaudeSessionFrom(path, info.Size(), 2, "")
955+
require.NoError(t, err)
956+
require.Len(t, newMsgs, 2,
957+
"the entry splits into a hidden IDE-context message plus the real prompt")
958+
959+
assert.True(t, newMsgs[0].IsSystem)
960+
assert.Equal(t, "system", newMsgs[0].SourceType)
961+
assert.Equal(t, "ide_opened_file", newMsgs[0].SourceSubtype)
962+
assert.Equal(t,
963+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file>",
964+
newMsgs[0].Content)
965+
966+
assert.False(t, newMsgs[1].IsSystem)
967+
assert.Equal(t, RoleUser, newMsgs[1].Role)
968+
assert.Equal(t, "Explain this file.", newMsgs[1].Content)
969+
}
970+
889971
func TestParseClaudeSessionFrom_ReminderPrefixedCommand(t *testing.T) {
890972
t.Parallel()
891973

0 commit comments

Comments
 (0)