Skip to content

Commit 9e5324c

Browse files
tylergibbs1mjacobs
andauthored
fix(claude): keep ide context out of session titles (#1252)
the claude code vscode extension writes file-open and selection hints as standalone user-role xml envelopes. agentsview currently treats them as operator prompts, so they can become sidebar titles, count as user turns, and render raw markup. complete standalone `ide_opened_file` and `ide_selection` envelopes now promote to hidden system metadata with distinct subtypes. the match is intentionally strict, so malformed wrappers and messages with real prompt text remain ordinary user content. dag fork selection applies the same user-text preprocessing before counting turns, so reminder-prefixed ide context cannot promote an obsolete branch. incremental sync keeps promptless ide and continuation appends bounded to the new bytes, falling back to a full parse only when the first title-eligible prompt arrives. the source data version advances to 74 because main now uses 73 for opencode bash exit-failure detection. existing sqlite archives are reparsed on upgrade, and duckdb mirrors rebuild on their next push through the independent source data-version gate; the mirror schema is unchanged. closes #1238 --------- Co-authored-by: Matthew Jacobs <mjacobs@apache.org>
1 parent e7adb12 commit 9e5324c

8 files changed

Lines changed: 388 additions & 40 deletions

File tree

docs/internal/session-format-sources.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ Grok section and remove the explicit registry exception in the coverage test.
7575
`attachment.type=queued_command` are written mid-stream, in file order
7676
between consecutive `assistant` records that share one `message.id`, so a
7777
queued command can fall inside a streaming run that straddles an incremental
78-
sync boundary.
78+
sync boundary. Reverified 2026-07-23 against the transcript shape reported in
79+
[#1238](https://github.com/kenn-io/agentsview/issues/1238): Claude Code for
80+
VS Code writes standalone `user` records wrapped in `ide_opened_file` or
81+
`ide_selection` tags for editor context rather than operator prompts.
7982

8083
## OpenClaude (`openclaude`)
8184

internal/db/db.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,10 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
330330
// status N" output text, so existing rows carry no failure signal. Re-parsing
331331
// attaches the errored event so tool-health failure counts cover historical
332332
// OpenCode sessions on every platform.)
333-
const dataVersion = 73
333+
// (74: Claude Code IDE context reparse. Standalone ide_opened_file and
334+
// ide_selection wrappers are promoted to system metadata so existing
335+
// VS Code sessions no longer use them as titles or user turns.)
336+
const dataVersion = 74
334337

335338
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
336339

internal/db/db_test.go

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

1010-
func TestCurrentDataVersionOpenCodeBashExitFailure(t *testing.T) {
1011-
assert.Equal(t, 73, CurrentDataVersion(),
1012-
"OpenCode bash metadata.exit failure detection requires a data version bump")
1010+
func TestCurrentDataVersionClaudeIDEContext(t *testing.T) {
1011+
assert.Equal(t, 74, CurrentDataVersion(),
1012+
"Claude IDE context parsing requires a data version bump")
10131013
}
10141014

10151015
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/claude.go

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,9 +1169,8 @@ func parseLinear(
11691169
endedAt = laterTime(globalEnd, endedAt)
11701170
annotateSubagentSessions(messages, subagentMap)
11711171

1172-
// Promoted system messages (continuation/resume/interrupted/
1173-
// task_notification/stop_hook) carry Role=user so role-keyed
1174-
// analytics ignore them, but they are not real user turns;
1172+
// Promoted system messages carry Role=user so role-keyed analytics
1173+
// ignore them, but they are not real user turns;
11751174
// firstMessageAndUserCount skips them when computing
11761175
// user_message_count / first_message. It also skips leading
11771176
// /clear and /effort command envelopes so the sidebar shows
@@ -1982,11 +1981,10 @@ func pathWithinDir(path, dir string) bool {
19821981
rel != ".."
19831982
}
19841983

1985-
// countUserTurns counts all user entries reachable from a
1986-
// starting index by traversing the entire subtree. Earlier
1987-
// versions followed only the first child at each node, which
1988-
// undercounted in sessions with many nested forks and caused
1989-
// the fork heuristic to discard the main conversation branch.
1984+
// countUserTurns counts real user entries reachable from a starting index
1985+
// by traversing the entire subtree. System-injected user records must not
1986+
// influence branch selection because they are promoted to system metadata
1987+
// when messages are extracted.
19901988
func countUserTurns(
19911989
entries []dagEntry,
19921990
children map[string][]int,
@@ -1997,14 +1995,29 @@ func countUserTurns(
19971995
for len(stack) > 0 {
19981996
current := stack[len(stack)-1]
19991997
stack = stack[:len(stack)-1]
2000-
if entries[current].entryType == "user" {
1998+
if isCountedClaudeUserTurn(entries[current]) {
20011999
count++
20022000
}
20032001
stack = append(stack, children[entries[current].uuid]...)
20042002
}
20052003
return count
20062004
}
20072005

2006+
func isCountedClaudeUserTurn(entry dagEntry) bool {
2007+
if entry.entryType != "user" ||
2008+
gjson.Get(entry.line, "isMeta").Bool() ||
2009+
gjson.Get(entry.line, "isCompactSummary").Bool() {
2010+
return false
2011+
}
2012+
content := gjson.Get(entry.line, "message.content")
2013+
text, _, _, _, _, _ := ExtractTextContent(content)
2014+
text, skip := preprocessClaudeUserText(text)
2015+
if skip || strings.TrimSpace(text) == "" {
2016+
return false
2017+
}
2018+
return !isClaudeSystemMessage(text)
2019+
}
2020+
20082021
// extractMessages converts dagEntries into ParsedMessages, applying
20092022
// the same filtering and content extraction as the original linear
20102023
// parser.
@@ -2422,15 +2435,15 @@ func isCommandEnvelope(content string) bool {
24222435
return strings.TrimSpace(stripped) == ""
24232436
}
24242437

2425-
// isSkippablePreviewCommand returns true when content is a Claude
2438+
// IsSkippablePreviewCommand returns true when content is a Claude
24262439
// Code slash command (e.g. /login, /plan, /roborev-fix). Detection
24272440
// is generic: the trimmed content must start with "/" followed by one
24282441
// or more letters, digits, hyphens, or underscores, then either end
24292442
// or be followed by whitespace. Hyphens and underscores are included
24302443
// because command envelopes normalise to names like /skill-name.
24312444
// File-path references like "/usr/local/bin gives an error" are not
24322445
// skipped because the embedded "/" terminates the match.
2433-
func isSkippablePreviewCommand(content string) bool {
2446+
func IsSkippablePreviewCommand(content string) bool {
24342447
trimmed := strings.TrimSpace(content)
24352448
if !strings.HasPrefix(trimmed, "/") {
24362449
return false
@@ -2472,7 +2485,7 @@ func firstMessageAndUserCount(
24722485
}
24732486
userCount++
24742487
if firstMsg == "" &&
2475-
!isSkippablePreviewCommand(m.Content) {
2488+
!IsSkippablePreviewCommand(m.Content) {
24762489
firstMsg = truncate(
24772490
strings.ReplaceAll(m.Content, "\n", " "), 300,
24782491
)
@@ -2565,10 +2578,28 @@ func classifyClaudeSystemMessage(content string) string {
25652578
return ""
25662579
}
25672580
return "system_reminder"
2581+
case isStandaloneClaudeTaggedMessage(trimmed, "ide_opened_file"):
2582+
return "ide_opened_file"
2583+
case isStandaloneClaudeTaggedMessage(trimmed, "ide_selection"):
2584+
return "ide_selection"
25682585
}
25692586
return ""
25702587
}
25712588

2589+
func isStandaloneClaudeTaggedMessage(content, tag string) bool {
2590+
trimmed := strings.TrimSpace(content)
2591+
openTag := "<" + tag + ">"
2592+
closeTag := "</" + tag + ">"
2593+
if !strings.HasPrefix(trimmed, openTag) ||
2594+
!strings.HasSuffix(trimmed, closeTag) {
2595+
return false
2596+
}
2597+
2598+
afterOpen := trimmed[len(openTag):]
2599+
return strings.Index(afterOpen, closeTag) ==
2600+
len(afterOpen)-len(closeTag)
2601+
}
2602+
25722603
func stripLeadingClaudeSystemReminderContent(content string) string {
25732604
trimmed := trimClaudeSystemMessagePrefix(content)
25742605
remainder, stripped := stripLeadingClaudeSystemReminderBlocks(trimmed)

internal/parser/claude_parser_test.go

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -285,20 +285,29 @@ func TestParseClaudeSession_SkippedMessages(t *testing.T) {
285285
// Non-caveat local-command is pure noise and stays skipped.
286286
testjsonl.ClaudeUserJSON("<local-command-result>ok</local-command-result>", "2024-01-01T00:00:05Z"),
287287
testjsonl.ClaudeUserJSON("Stop hook feedback: rejected", "2024-01-01T00:00:06Z"),
288-
testjsonl.ClaudeUserJSON("real user message", "2024-01-01T00:00:07Z"),
288+
testjsonl.ClaudeUserJSON(
289+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file>",
290+
"2024-01-01T00:00:07Z",
291+
),
292+
testjsonl.ClaudeUserJSON(
293+
"<ide_selection>The user selected package main.</ide_selection>",
294+
"2024-01-01T00:00:08Z",
295+
),
296+
testjsonl.ClaudeUserJSON("real user message", "2024-01-01T00:00:09Z"),
289297
)
290298
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
291-
// 5 promoted system + 1 real user; <local-command-result>
299+
// 7 promoted system + 1 real user; <local-command-result>
292300
// is still skipped.
293-
assert.Equal(t, 6, sess.MessageCount)
301+
assert.Equal(t, 8, sess.MessageCount)
294302
assert.Equal(t, 1, sess.UserMessageCount)
295303
assert.Equal(t, "real user message", sess.FirstMessage)
296304

297305
wantSubtypes := []string{
298306
"continuation", "interrupted", "resume",
299307
"task_notification", "stop_hook",
308+
"ide_opened_file", "ide_selection",
300309
}
301-
require.Len(t, msgs, 6)
310+
require.Len(t, msgs, 8)
302311
for i, want := range wantSubtypes {
303312
assert.True(t, msgs[i].IsSystem,
304313
"msgs[%d] should be system", i)
@@ -310,9 +319,9 @@ func TestParseClaudeSession_SkippedMessages(t *testing.T) {
310319
assert.Equal(t, want, msgs[i].SourceSubtype)
311320
}
312321
// Final message is the real user message.
313-
assert.False(t, msgs[5].IsSystem)
314-
assert.Equal(t, RoleUser, msgs[5].Role)
315-
assert.Equal(t, "real user message", msgs[5].Content)
322+
assert.False(t, msgs[7].IsSystem)
323+
assert.Equal(t, RoleUser, msgs[7].Role)
324+
assert.Equal(t, "real user message", msgs[7].Content)
316325
})
317326

318327
t.Run("skill invocation shown as user message", func(t *testing.T) {
@@ -821,6 +830,45 @@ func TestParseClaudeSessionFrom_QueuedSystemMessage(t *testing.T) {
821830
assert.Equal(t, "system_reminder", newMsgs[0].SourceSubtype)
822831
}
823832

833+
func TestParseClaudeSessionFrom_IDEContext(t *testing.T) {
834+
t.Parallel()
835+
836+
initial := testjsonl.JoinJSONL(
837+
testjsonl.ClaudeUserJSON("hello", tsEarly),
838+
testjsonl.ClaudeAssistantJSON("hi", tsEarlyS1),
839+
)
840+
path := createTestFile(t, "inc-ide-context.jsonl", initial)
841+
info, err := os.Stat(path)
842+
require.NoError(t, err)
843+
844+
appended := testjsonl.JoinJSONL(
845+
testjsonl.ClaudeUserJSON(
846+
"<ide_opened_file>The user opened /workspace/app/README.md.</ide_opened_file>",
847+
tsEarlyS5,
848+
),
849+
testjsonl.ClaudeUserJSON(
850+
"<ide_selection>The user selected package main.</ide_selection>",
851+
tsLate,
852+
),
853+
)
854+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
855+
require.NoError(t, err)
856+
_, err = f.WriteString(appended)
857+
require.NoError(t, err)
858+
require.NoError(t, f.Close())
859+
860+
newMsgs, _, _, err := callParseClaudeSessionFrom(path, info.Size(), 2, "")
861+
require.NoError(t, err)
862+
require.Len(t, newMsgs, 2)
863+
for i, subtype := range []string{"ide_opened_file", "ide_selection"} {
864+
assert.Equal(t, RoleUser, newMsgs[i].Role)
865+
assert.True(t, newMsgs[i].IsSystem)
866+
assert.Equal(t, "system", newMsgs[i].SourceType)
867+
assert.Equal(t, subtype, newMsgs[i].SourceSubtype)
868+
assert.Equal(t, i+2, newMsgs[i].Ordinal)
869+
}
870+
}
871+
824872
func TestParseClaudeSessionFrom_ReminderPrefixedCommand(t *testing.T) {
825873
t.Parallel()
826874

@@ -2578,6 +2626,10 @@ func TestClassifyClaudeSystemMessage(t *testing.T) {
25782626
{"system_reminder", "<system-reminder>remember this</system-reminder>", "system_reminder"},
25792627
{"system_reminder plus prompt", "<system-reminder>remember this</system-reminder>\n\nreal prompt", ""},
25802628
{"malformed system_reminder", "<system-reminder>literal tag at the start", ""},
2629+
{"opened file", "\uFEFF <ide_opened_file>The user opened README.md.</ide_opened_file>\n", "ide_opened_file"},
2630+
{"selection", "<ide_selection>The user selected package main.</ide_selection>", "ide_selection"},
2631+
{"selection plus prompt", "<ide_selection>package main</ide_selection>\n\nexplain this", ""},
2632+
{"malformed opened file", "<ide_opened_file>The user opened README.md.", ""},
25812633
{"task-notification-status", "<task-notification-status>ready", ""},
25822634
{"bom prefix", "\uFEFF This session is being continued", "continuation"},
25832635
{"non-caveat local-command", "<local-command-stdout>foo</local-command-stdout>", ""},
@@ -2671,7 +2723,7 @@ func TestIsSkippablePreviewCommand(t *testing.T) {
26712723
}
26722724
for _, tc := range cases {
26732725
t.Run(tc.name, func(t *testing.T) {
2674-
got := isSkippablePreviewCommand(tc.content)
2726+
got := IsSkippablePreviewCommand(tc.content)
26752727
assert.Equal(t, tc.want, got,
26762728
"content=%q", tc.content)
26772729
})

internal/parser/fork_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,47 @@ func TestForkDetection_SmallGapRetry(t *testing.T) {
117117
assertMessage(t, results[0].Messages[3], RoleAssistant, "retry answer")
118118
}
119119

120+
func TestForkDetection_ReminderPrefixedIDEContextDoesNotPromoteObsoleteBranch(
121+
t *testing.T,
122+
) {
123+
const reminder = "<system-reminder>context</system-reminder>\n"
124+
content := testjsonl.NewSessionBuilder().
125+
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "start", "a", "").
126+
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "ok", "b", "a").
127+
AddClaudeUserWithUUID(
128+
"2024-01-01T10:00:02Z",
129+
reminder+"<ide_opened_file>one</ide_opened_file>",
130+
"c", "b",
131+
).
132+
AddClaudeUserWithUUID(
133+
"2024-01-01T10:00:03Z",
134+
reminder+"<ide_selection>two</ide_selection>",
135+
"d", "c",
136+
).
137+
AddClaudeUserWithUUID(
138+
"2024-01-01T10:00:04Z",
139+
reminder+"<ide_opened_file>three</ide_opened_file>",
140+
"e", "d",
141+
).
142+
AddClaudeUserWithUUID(
143+
"2024-01-01T10:00:05Z",
144+
reminder+"<ide_selection>four</ide_selection>",
145+
"f", "e",
146+
).
147+
AddClaudeUserWithUUID(
148+
"2024-01-01T10:01:00Z", "real retry", "z", "b",
149+
).
150+
AddClaudeAssistantWithUUID(
151+
"2024-01-01T10:01:01Z", "retry answer", "zz", "z",
152+
).
153+
String()
154+
155+
results := parseTestContent(t, "ide-context-fork.jsonl", content, 1)
156+
require.Len(t, results[0].Messages, 4)
157+
assert.Equal(t, "real retry", results[0].Messages[2].Content)
158+
assert.Equal(t, "retry answer", results[0].Messages[3].Content)
159+
}
160+
120161
func TestForkDetection_NoUUIDs(t *testing.T) {
121162
// Entries without uuid fields — should work as before, 1 result.
122163
content := testjsonl.NewSessionBuilder().

internal/sync/engine.go

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8507,21 +8507,6 @@ func (e *Engine) tryIncrementalJSONL(
85078507
return processResult{}, false
85088508
}
85098509

8510-
// Claude-only: if the stored preview is empty despite the
8511-
// session already having user turns, the parser skipped
8512-
// every user message so far (e.g. a session that opens with
8513-
// /clear or /effort). Fall back to a full parse so any real
8514-
// user message appended this sync becomes first_message.
8515-
//
8516-
// Other agents can legitimately have UserMsgCount > 0 with
8517-
// an empty first_message — for example Codex inserts orphan
8518-
// subagent notifications as Role=user messages that bypass
8519-
// firstMessage — so this fall-through is gated on Claude.
8520-
if agent == parser.AgentClaude &&
8521-
inc.FirstMessage == "" && inc.UserMsgCount > 0 {
8522-
return processResult{}, false
8523-
}
8524-
85258510
currentSize := info.Size()
85268511

85278512
// A prior sync that stored no message rows has no safe append
@@ -8708,6 +8693,31 @@ func (e *Engine) tryIncrementalJSONL(
87088693
}
87098694
}
87108695

8696+
// Claude-only: an empty stored preview means no real user prompt
8697+
// has been parsed yet (a session that starts with injected IDE
8698+
// context, a continuation record, /clear, or /effort). When this
8699+
// chunk carries the first real prompt, fall back to a full parse
8700+
// so first_message is re-derived from the whole file. Chunks
8701+
// without one — streamed assistant work after an auto-compact
8702+
// continuation, or more injected IDE context — stay incremental
8703+
// so per-event work is bounded by the appended bytes rather than
8704+
// the transcript size.
8705+
//
8706+
// Other agents can legitimately have an empty first_message
8707+
// alongside real user rows — for example Codex inserts orphan
8708+
// subagent notifications as Role=user messages that bypass
8709+
// firstMessage — so this fall-through is gated on Claude.
8710+
if agent == parser.AgentClaude && inc.FirstMessage == "" &&
8711+
chunkHasRealUserPrompt(newMsgs) {
8712+
log.Printf(
8713+
"incremental %s %s: first real user prompt after "+
8714+
"empty preview, full parse",
8715+
agent, file.Path,
8716+
)
8717+
lease.Release()
8718+
return processResult{}, false
8719+
}
8720+
87118721
newUserCount := countUserMsgs(newMsgs)
87128722
nextOrdinal := nextParsedOrdinal(inc.NextOrdinal, newMsgs)
87138723
lastEntryUUID := lastParsedSourceUUID(inc.LastEntryUUID, newMsgs)
@@ -11697,6 +11707,25 @@ func postFilterCounts(msgs []db.Message) (total, user int) {
1169711707
return len(msgs), user
1169811708
}
1169911709

11710+
// chunkHasRealUserPrompt reports whether msgs contains a message the
11711+
// Claude parser would use as first_message (mirroring the firstMsg
11712+
// rule in firstMessageAndUserCount): role user, not system-injected,
11713+
// non-empty content, and not a bare slash command. Promoted system
11714+
// records (IDE context, continuation, stop-hook), tool-result-only
11715+
// rows, and preview-skipped commands like /clear do not qualify —
11716+
// a full parse triggered by those would leave first_message empty
11717+
// and the next append would full-parse again.
11718+
func chunkHasRealUserPrompt(msgs []parser.ParsedMessage) bool {
11719+
for _, m := range msgs {
11720+
if m.Role == parser.RoleUser && !m.IsSystem &&
11721+
m.Content != "" &&
11722+
!parser.IsSkippablePreviewCommand(m.Content) {
11723+
return true
11724+
}
11725+
}
11726+
return false
11727+
}
11728+
1170011729
// countUserMsgs counts user messages in parsed messages.
1170111730
func countUserMsgs(msgs []parser.ParsedMessage) int {
1170211731
n := 0

0 commit comments

Comments
 (0)