Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/internal/session-format-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -1650,7 +1650,9 @@ schemas keep their existing ordering behavior.
`toolSpecificData.commandLine.original`, and ordered `inlineReference`
response items. Agentsview consumes the final response array, which also
preserves display order, rather than the duplicate tool calls under
`result.metadata.toolCallRounds`.
`result.metadata.toolCallRounds`. Each assistant message records the model
that served its turn (`result.metadata.resolvedModel`, falling back to the
request's prefixed `modelId`).

## Windsurf (`windsurf`)

Expand Down
47 changes: 40 additions & 7 deletions internal/parser/vscode_copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ func parseVSCodeCopilotData(
continue
}

// The model is recorded per message because a session can
// switch models between turns; the per-turn usage event
// carries the same value for cost accounting.
md, _ := vscodeCopilotMetadataOf(req)

messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleAssistant,
Expand All @@ -260,6 +265,7 @@ func parseVSCodeCopilotData(
HasToolUse: hasToolUse,
ContentLength: len(displayContent),
ToolCalls: toolCalls,
Model: vscodeCopilotModel(req, md),
})
ordinal++
}
Expand Down Expand Up @@ -329,8 +335,8 @@ func vscodeCopilotUsageEvent(
if req.Result == nil || len(req.Result.Metadata) == 0 {
return ParsedUsageEvent{}, false
}
var md vscodeCopilotMetadata
if err := json.Unmarshal(req.Result.Metadata, &md); err != nil {
md, ok := vscodeCopilotMetadataOf(req)
if !ok {
return ParsedUsageEvent{}, false
}
if md.PromptTokens <= 0 && md.OutputTokens <= 0 {
Expand All @@ -340,11 +346,7 @@ func vscodeCopilotUsageEvent(
// resolvedModel is already in pricing-catalog form
// (e.g. "claude-opus-4-8"). Fall back to the prefixed modelId
// (e.g. "copilot/claude-opus-4.8") and normalize it.
model := md.ResolvedModel
if model == "" {
model = strings.TrimPrefix(req.ModelID, "copilot/")
}
model = normalizeCopilotModel(model)
model := vscodeCopilotModel(req, md)

return ParsedUsageEvent{
Source: "vscode-copilot",
Expand All @@ -355,6 +357,37 @@ func vscodeCopilotUsageEvent(
}, true
}

// vscodeCopilotMetadataOf decodes a request's result.metadata, which
// carries the per-turn token accounting and resolved model. ok is
// false when the request has no metadata object.
func vscodeCopilotMetadataOf(
req vscodeCopilotRequest,
) (vscodeCopilotMetadata, bool) {
if req.Result == nil || len(req.Result.Metadata) == 0 {
return vscodeCopilotMetadata{}, false
}
var md vscodeCopilotMetadata
if err := json.Unmarshal(req.Result.Metadata, &md); err != nil {
return vscodeCopilotMetadata{}, false
}
return md, true
}

// vscodeCopilotModel resolves the model that served a request, for
// both its usage event and its assistant message. resolvedModel is
// already in pricing-catalog form (e.g. "claude-opus-4-8"); the
// prefixed modelId (e.g. "copilot/claude-opus-4.8") is the fallback
// used when a request carries no metadata.
func vscodeCopilotModel(
req vscodeCopilotRequest, md vscodeCopilotMetadata,
) string {
model := md.ResolvedModel
if model == "" {
model = strings.TrimPrefix(req.ModelID, "copilot/")
}
return normalizeCopilotModel(model)
}

// parseVSCodeCopilotResponse extracts text and tool calls
// from the response items array.
func parseVSCodeCopilotResponse(
Expand Down
89 changes: 89 additions & 0 deletions internal/parser/vscode_copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1374,3 +1374,92 @@ func TestFormatVSCodeCopilotToolCallsRecordsRenderings(t *testing.T) {
assert.Contains(t, text, calls[0].Rendering)
assert.Contains(t, text, calls[1].Rendering)
}

func TestParseVSCodeCopilotSession_AssistantMessageModel(t *testing.T) {
tests := []struct {
name string
requestsJSON string
wantModels []string
}{
{
name: "metadata resolved model and modelId fallback",
requestsJSON: `{
"requestId": "r1",
"message": {"text": "First"},
"response": [{"value": "answer one"}],
"timestamp": 1755340000000,
"modelId": "copilot/claude-opus-4.8",
"result": {"metadata": {
"promptTokens": 35875,
"outputTokens": 221,
"resolvedModel": "claude-opus-4-8"
}}
},
{
"requestId": "r2",
"message": {"text": "Second"},
"response": [{"value": "answer two"}],
"timestamp": 1755345000000,
"modelId": "copilot/claude-opus-4.8"
}`,
wantModels: []string{"claude-opus-4-8", "claude-opus-4-8"},
},
{
name: "model follows the turn that produced the response",
requestsJSON: `{
"requestId": "r1",
"message": {"text": "First"},
"response": [{"value": "answer one"}],
"timestamp": 1755340000000,
"modelId": "copilot/claude-opus-4.8"
},
{
"requestId": "r2",
"message": {"text": "Second"},
"response": [{"value": "answer two"}],
"timestamp": 1755345000000,
"modelId": "copilot/gpt-5"
}`,
wantModels: []string{"claude-opus-4-8", "gpt-5"},
},
{
name: "turn without a model",
requestsJSON: `{
"requestId": "r1",
"message": {"text": "First"},
"response": [{"value": "answer one"}],
"timestamp": 1755340000000
}`,
wantModels: []string{""},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sessionJSON := `{
"version": 3,
"sessionId": "model-1",
"creationDate": 1755340000000,
"lastMessageDate": 1755350000000,
"requests": [` + tc.requestsJSON + `]
}`

dir := t.TempDir()
path := filepath.Join(dir, "model.json")
require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644))

_, msgs, err := parseVSCodeCopilotTestSession(t, path, "proj", "local")
require.NoError(t, err)
require.Len(t, msgs, 2*len(tc.wantModels), "user and assistant per turn")

for i, want := range tc.wantModels {
user := msgs[2*i]
assistant := msgs[2*i+1]
assert.Equal(t, RoleUser, user.Role, "turn %d user role", i)
assert.Empty(t, user.Model, "turn %d user model", i)
assert.Equal(t, RoleAssistant, assistant.Role, "turn %d assistant role", i)
assert.Equal(t, want, assistant.Model, "turn %d assistant model", i)
}
})
}
}
Loading