f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
1588 lines
52 KiB
Go
1588 lines
52 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/export"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestSessionSummaryExportRowsAreContentFreeAndMetadataScoped(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
seedSessionExportPricing(t, d)
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
Project: "alpha",
|
|
Machine: "laptop",
|
|
RootPath: "/repo/alpha/worktrees/feature",
|
|
GitRemote: "https://github.com/acme/alpha.git",
|
|
GitRemoteName: "origin",
|
|
WorktreeName: "feature",
|
|
WorktreeRootPath: "/repo/alpha",
|
|
ObservedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
|
}), "upsert alpha project identity")
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
Project: "unreturned",
|
|
Machine: "laptop",
|
|
RootPath: "/repo/unreturned",
|
|
GitRemote: "https://github.com/acme/unreturned.git",
|
|
ObservedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
|
}), "upsert unrelated project identity")
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
Project: "alpha",
|
|
Machine: "laptop",
|
|
RootPath: "/repo/alpha/main",
|
|
GitRemote: "https://github.com/acme/alpha.git",
|
|
GitRemoteName: "origin",
|
|
WorktreeName: "main",
|
|
WorktreeRootPath: "/repo/alpha/main",
|
|
ObservedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
|
}), "upsert alpha main project identity")
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "alpha-parent",
|
|
Project: "alpha",
|
|
Machine: "laptop",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:10:00Z"),
|
|
MessageCount: 3,
|
|
UserMessageCount: 1,
|
|
TotalOutputTokens: 90,
|
|
PeakContextTokens: 1200,
|
|
HasTotalOutputTokens: true,
|
|
HasPeakContextTokens: true,
|
|
Cwd: "/repo/alpha/worktrees/feature/pkg",
|
|
GitBranch: "feature/export-contracts",
|
|
})
|
|
insertMessages(t, d,
|
|
Message{
|
|
SessionID: "alpha-parent", Ordinal: 0, Role: "user",
|
|
Timestamp: "2026-05-01T10:00:30Z",
|
|
},
|
|
Message{
|
|
SessionID: "alpha-parent", Ordinal: 1, Role: "assistant",
|
|
Timestamp: "2026-05-01T10:01:00Z",
|
|
Model: "model-computed",
|
|
TokenUsage: json.RawMessage(
|
|
`{"input_tokens":1000,"output_tokens":500,` +
|
|
`"cache_creation_input_tokens":200,` +
|
|
`"cache_read_input_tokens":300}`),
|
|
HasContextTokens: true, HasOutputTokens: true,
|
|
},
|
|
Message{
|
|
SessionID: "alpha-parent", Ordinal: 2, Role: "assistant",
|
|
Timestamp: "2026-05-01T10:02:00Z",
|
|
Model: "model-reported",
|
|
TokenUsage: json.RawMessage(
|
|
`{"input_tokens":20,"output_tokens":10}`),
|
|
HasContextTokens: true, HasOutputTokens: true,
|
|
},
|
|
)
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "alpha-child",
|
|
Project: "alpha",
|
|
Machine: "laptop",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T10:02:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:05:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
ParentSessionID: Ptr("alpha-parent"),
|
|
RelationshipType: "subagent",
|
|
IsAutomated: true,
|
|
Cwd: "/repo/alpha/worktrees/feature/sub",
|
|
})
|
|
reported := 0.0123
|
|
require.NoError(t, d.ReplaceSessionUsageEvents("alpha-child", []UsageEvent{{
|
|
Source: "provider",
|
|
Model: "model-reported",
|
|
InputTokens: 200,
|
|
OutputTokens: 40,
|
|
ReasoningTokens: 7,
|
|
CostUSD: &reported,
|
|
OccurredAt: "2026-05-01T10:04:00Z",
|
|
}}), "replace usage events")
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "beta-root",
|
|
Project: "beta",
|
|
Machine: "desktop",
|
|
Agent: "codex",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T09:30:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 2,
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "beta-root", Ordinal: 0, Role: "assistant",
|
|
Timestamp: "2026-05-01T09:10:00Z",
|
|
Model: "model-reported",
|
|
TokenUsage: json.RawMessage(
|
|
`{"input_tokens":10,"output_tokens":20}`),
|
|
HasContextTokens: true, HasOutputTokens: true,
|
|
})
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{IncludeChildren: true},
|
|
Limit: 10,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
require.Len(t, result.Rows, 3, "export rows")
|
|
|
|
rows := sessionExportRowsByID(result.Rows)
|
|
parent := rows["alpha-parent"]
|
|
require.NotNil(t, parent.ModelUsage, "parent model usage")
|
|
assert.Equal(t, "alpha-parent", parent.ID)
|
|
assert.Equal(t, "alpha", parent.Project)
|
|
assert.Equal(t, "laptop", parent.Machine)
|
|
assert.Equal(t, "/repo/alpha/worktrees/feature/pkg", parent.Cwd)
|
|
assert.Equal(t, "feature/export-contracts", parent.GitBranch)
|
|
assert.Equal(t, 3, parent.MessageCount)
|
|
assert.Equal(t, 1, parent.UserMessageCount)
|
|
assert.Equal(t, 2, parent.AssistantMessageCount)
|
|
assert.Equal(t, 1, parent.TurnCount)
|
|
require.NotNil(t, parent.DurationSeconds, "duration_seconds")
|
|
assert.Equal(t, int64(600), *parent.DurationSeconds)
|
|
assert.Equal(t, export.SessionClassificationInteractive, parent.Classification)
|
|
assert.False(t, parent.IsAutomated)
|
|
assert.Nil(t, parent.ParentSessionID)
|
|
assert.Nil(t, parent.RelationshipType)
|
|
assert.Equal(t, []string{"model-computed", "model-reported"}, parent.ModelUsage.Models)
|
|
assert.Equal(t, 1020, parent.ModelUsage.InputTokens)
|
|
assert.Equal(t, 510, parent.ModelUsage.OutputTokens)
|
|
assert.Equal(t, 200, parent.ModelUsage.CacheCreationInputTokens)
|
|
assert.Equal(t, 300, parent.ModelUsage.CacheReadInputTokens)
|
|
assert.True(t, parent.ModelUsage.HasCost)
|
|
require.Contains(t, parent.ModelUsage.ByModel, "model-computed")
|
|
assert.Equal(t, 1000, parent.ModelUsage.ByModel["model-computed"].InputTokens)
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
parent.ModelUsage.ByModel["model-computed"].CostSource)
|
|
require.Contains(t, parent.ModelUsage.ByModel, "model-reported")
|
|
assert.Equal(t, 20, parent.ModelUsage.ByModel["model-reported"].InputTokens)
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
parent.ModelUsage.ByModel["model-reported"].CostSource)
|
|
require.NotNil(t, parent.Worktree, "parent worktree")
|
|
assert.Equal(t, Ptr("feature"), parent.Worktree.Name)
|
|
assert.Equal(t, Ptr("/repo/alpha"), parent.Worktree.RootPath)
|
|
|
|
child := rows["alpha-child"]
|
|
require.NotNil(t, child.ModelUsage, "child model usage")
|
|
assert.Equal(t, export.SessionClassificationAutomated, child.Classification)
|
|
assert.True(t, child.IsAutomated)
|
|
require.NotNil(t, child.ParentSessionID, "child parent_session_id")
|
|
assert.Equal(t, "alpha-parent", *child.ParentSessionID)
|
|
require.NotNil(t, child.RelationshipType, "child relationship_type")
|
|
assert.Equal(t, "subagent", *child.RelationshipType)
|
|
assert.Equal(t, 7, child.ModelUsage.ReasoningTokens)
|
|
assert.Equal(t, reported, child.ModelUsage.CostUSD)
|
|
assert.True(t, child.ModelUsage.HasCost)
|
|
require.NotNil(t, child.Worktree, "child worktree")
|
|
assert.Equal(t, Ptr("feature"), child.Worktree.Name)
|
|
assert.Equal(t, Ptr("/repo/alpha"), child.Worktree.RootPath)
|
|
|
|
beta := rows["beta-root"]
|
|
require.NotNil(t, beta.ModelUsage, "beta model usage")
|
|
assert.Equal(t, "desktop", beta.Machine)
|
|
assert.Nil(t, beta.Worktree)
|
|
|
|
require.NotNil(t, result.Pricing, "pricing block")
|
|
assert.ElementsMatch(t, []string{"model-computed", "model-reported"},
|
|
sortedSetKeysFromMap(result.Pricing.Models))
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
result.Pricing.Models["model-computed"].CostSource)
|
|
assert.Equal(t, export.CostSourceMixed,
|
|
result.Pricing.Models["model-reported"].CostSource)
|
|
projectLabels := make([]string, 0, len(result.Projects))
|
|
for key, project := range result.Projects {
|
|
assert.NotContains(t, key, "alpha")
|
|
assert.NotContains(t, key, "beta")
|
|
projectLabels = append(projectLabels, project.DisplayLabel)
|
|
}
|
|
assert.ElementsMatch(t, []string{"alpha", "beta"}, projectLabels)
|
|
assertContentFreeJSON(t, result)
|
|
|
|
for _, row := range result.Rows {
|
|
assertJSONHasKey(t, row, "parent_session_id")
|
|
assertJSONHasKey(t, row, "relationship_type")
|
|
assertContentFreeJSON(t, row)
|
|
}
|
|
}
|
|
|
|
func TestSessionSummaryExportKeepsFirstConclusiveProjectIdentity(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
insertExportSession(t, d, Session{
|
|
ID: "stable-project-session",
|
|
Project: "/Users/alice/private/project",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
Cwd: "/Users/alice/private/project/pkg",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
first := export.ProjectIdentityObservation{
|
|
SessionID: "stable-project-session",
|
|
Project: "/Users/alice/private/project",
|
|
Machine: "laptop",
|
|
RootPath: "/Users/alice/private/project",
|
|
GitRemote: "https://github.com/acme/project.git",
|
|
GitRemoteName: "origin",
|
|
RepositoryPath: "/Users/alice/private/project",
|
|
WorktreeRootPath: "/Users/alice/private/project",
|
|
WorktreeRelationship: export.WorktreeMain,
|
|
CheckoutState: export.CheckoutBranch,
|
|
GitBranch: "main",
|
|
ObservedAt: time.Date(2026, 5, 1, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx, first))
|
|
|
|
missingDirectoryRefresh := first
|
|
missingDirectoryRefresh.RootPath = "/Users/alice/private/project/pkg"
|
|
missingDirectoryRefresh.GitRemote = ""
|
|
missingDirectoryRefresh.GitRemoteName = ""
|
|
missingDirectoryRefresh.RemoteResolution = export.ProjectResolutionUnknown
|
|
missingDirectoryRefresh.ObservedAt = first.ObservedAt.Add(time.Hour)
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx, missingDirectoryRefresh))
|
|
|
|
changedRemoteRefresh := first
|
|
changedRemoteRefresh.GitRemote = "https://gitlab.com/acme/other.git"
|
|
changedRemoteRefresh.ObservedAt = first.ObservedAt.Add(2 * time.Hour)
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx, changedRemoteRefresh))
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Rows, 1)
|
|
require.NotNil(t, result.Rows[0].ProjectReference.Identity)
|
|
assert.Equal(t, "github.com/acme/project",
|
|
result.Rows[0].ProjectReference.Identity.NormalizedRemote)
|
|
assert.NotEmpty(t, result.Rows[0].ProjectReference.Identity.RootKey,
|
|
"session row retains its immutable machine-local root")
|
|
assert.Equal(t, export.WorktreeMain,
|
|
result.Rows[0].ProjectReference.Worktree.Relationship)
|
|
assert.Equal(t, export.CheckoutBranch,
|
|
result.Rows[0].ProjectReference.Checkout.State)
|
|
assert.Equal(t, "main", result.Rows[0].ProjectReference.Checkout.Branch)
|
|
assert.Empty(t, result.Rows[0].ProjectReference.DisplayLabel)
|
|
project := result.Projects[result.Rows[0].ProjectReference.ProjectKey]
|
|
require.NotNil(t, project.Identity)
|
|
assert.Empty(t, project.Identity.RootKey,
|
|
"remote-backed catalog entries must not select a row-dependent root")
|
|
|
|
payload, err := json.Marshal(result)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(payload), "/Users/alice")
|
|
}
|
|
|
|
func TestSessionSummaryExportCatalogMarksConflictingSessionIdentitiesAmbiguous(
|
|
t *testing.T,
|
|
) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
for i, remote := range []string{
|
|
"https://github.com/acme/first.git",
|
|
"https://github.com/acme/second.git",
|
|
} {
|
|
id := fmt.Sprintf("shared-project-%d", i)
|
|
insertExportSession(t, d, Session{
|
|
ID: id, Project: "shared-project", Machine: "laptop", Agent: "codex",
|
|
StartedAt: Ptr(fmt.Sprintf("2026-05-01T10:0%d:00Z", i)),
|
|
EndedAt: Ptr(fmt.Sprintf("2026-05-01T10:0%d:30Z", i)),
|
|
})
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
SessionID: id, Project: "shared-project", Machine: "laptop",
|
|
RootPath: "/workspace/shared-project", GitRemote: remote,
|
|
GitRemoteName: "origin", RepositoryPath: "/workspace/shared-project",
|
|
WorktreeRootPath: "/workspace/shared-project",
|
|
WorktreeRelationship: export.WorktreeMain,
|
|
ObservedAt: time.Date(2026, 5, 1, 10, i, 0, 0, time.UTC),
|
|
},
|
|
))
|
|
}
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Rows, 2)
|
|
require.Len(t, result.Projects, 1)
|
|
for _, row := range result.Rows {
|
|
require.NotNil(t, row.ProjectReference.Identity)
|
|
assert.Equal(t, row.ProjectReference.ProjectKey,
|
|
result.Rows[0].ProjectReference.ProjectKey)
|
|
}
|
|
for _, project := range result.Projects {
|
|
assert.Equal(t, export.ProjectResolutionAmbiguous, project.Resolution)
|
|
assert.Nil(t, project.Identity)
|
|
}
|
|
}
|
|
|
|
func TestSessionSummaryExportKeepsConclusiveMachineRootSnapshot(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
insertExportSession(t, d, Session{
|
|
ID: "machine-root-session", Project: "local-app", Machine: "laptop",
|
|
Agent: "codex", Cwd: "/workspace/local-app",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
})
|
|
first := export.ProjectIdentityObservation{
|
|
SessionID: "machine-root-session", Project: "local-app",
|
|
Machine: "laptop", RootPath: "/workspace/local-app",
|
|
RepositoryPath: "/workspace/local-app",
|
|
RemoteResolution: export.ProjectResolutionResolved,
|
|
ObservedAt: time.Date(2026, 5, 1, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx, first))
|
|
changed := first
|
|
changed.GitRemote = "https://github.com/acme/local-app.git"
|
|
changed.ObservedAt = first.ObservedAt.Add(time.Hour)
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx, changed))
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Rows, 1)
|
|
require.NotNil(t, result.Rows[0].ProjectReference.Identity)
|
|
assert.Equal(t, export.ProjectKindMachineRoot,
|
|
result.Rows[0].ProjectReference.Identity.Kind)
|
|
assert.Empty(t, result.Rows[0].ProjectReference.Identity.NormalizedRemote)
|
|
}
|
|
|
|
func TestSessionSummaryExportWithoutSnapshotDoesNotDeriveFromCWD(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
insertExportSession(t, d, Session{
|
|
ID: "no-snapshot", Project: "app", Machine: "laptop",
|
|
Agent: "codex", Cwd: "/workspace/app/private/subdir",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
})
|
|
|
|
result, err := d.ExportSessionSummaries(
|
|
context.Background(), SessionExportOptions{Limit: 10},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Rows, 1)
|
|
assert.Equal(t, export.ProjectResolutionUnknown,
|
|
result.Rows[0].ProjectReference.Resolution)
|
|
assert.Nil(t, result.Rows[0].ProjectReference.Identity)
|
|
}
|
|
|
|
func TestSessionSummaryExportIncludesReasoningOnlyUsageRows(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
seedSessionExportPricing(t, d)
|
|
insertExportSession(t, d, Session{
|
|
ID: "reasoning-only",
|
|
Project: "alpha",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
})
|
|
require.NoError(t, d.ReplaceSessionUsageEvents("reasoning-only", []UsageEvent{{
|
|
Source: "provider",
|
|
Model: "model-computed",
|
|
ReasoningTokens: 25,
|
|
OccurredAt: "2026-05-01T10:00:30Z",
|
|
}}), "replace usage events")
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Limit: 10,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
require.Len(t, result.Rows, 1)
|
|
|
|
usage := result.Rows[0].ModelUsage
|
|
require.NotNil(t, usage, "reasoning-only usage must contribute")
|
|
assert.Equal(t, []string{"model-computed"}, usage.Models)
|
|
assert.Zero(t, usage.InputTokens)
|
|
assert.Zero(t, usage.OutputTokens)
|
|
assert.Equal(t, 25, usage.ReasoningTokens)
|
|
assert.True(t, usage.HasCost)
|
|
assert.InDelta(t, 0.0005, usage.CostUSD, 0.0000001)
|
|
require.Contains(t, usage.ByModel, "model-computed")
|
|
assert.Equal(t, 25, usage.ByModel["model-computed"].ReasoningTokens)
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
usage.ByModel["model-computed"].CostSource)
|
|
|
|
require.NotNil(t, result.Pricing)
|
|
require.Contains(t, result.Pricing.Models, "model-computed")
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
result.Pricing.Models["model-computed"].CostSource)
|
|
}
|
|
|
|
func TestSessionSummaryExportIncludesMessageReasoningTokens(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
seedSessionExportPricing(t, d)
|
|
insertExportSession(t, d, Session{
|
|
ID: "message-reasoning",
|
|
Project: "alpha",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "message-reasoning",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Timestamp: "2026-05-01T10:00:30Z",
|
|
Model: "model-computed",
|
|
TokenUsage: json.RawMessage(
|
|
`{"input_tokens":10,"output_tokens":0,"reasoning_tokens":25}`),
|
|
})
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Limit: 10,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
require.Len(t, result.Rows, 1)
|
|
|
|
usage := result.Rows[0].ModelUsage
|
|
require.NotNil(t, usage, "message reasoning usage must contribute")
|
|
assert.Equal(t, []string{"model-computed"}, usage.Models)
|
|
assert.Equal(t, 10, usage.InputTokens)
|
|
assert.Zero(t, usage.OutputTokens)
|
|
assert.Equal(t, 25, usage.ReasoningTokens)
|
|
assert.True(t, usage.HasCost)
|
|
assert.InDelta(t, 0.0006, usage.CostUSD, 0.0000001)
|
|
require.Contains(t, usage.ByModel, "model-computed")
|
|
assert.Equal(t, 25, usage.ByModel["model-computed"].ReasoningTokens)
|
|
}
|
|
|
|
func TestSessionSummaryExportRequiresExistingDatabaseID(t *testing.T) {
|
|
d := testDB(t)
|
|
insertExportSession(t, d, Session{
|
|
ID: "missing-db-id",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "codex",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
_, err := d.rawWriter().Exec(`
|
|
DELETE FROM archive_metadata WHERE key = ?`,
|
|
archiveMetadataDatabaseIDKey,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = d.ExportSessionSummaries(context.Background(), SessionExportOptions{
|
|
Limit: 10,
|
|
})
|
|
require.ErrorIs(t, err, ErrDatabaseIDMissing)
|
|
}
|
|
|
|
func TestSessionSummaryExportUsesMessageActivityForOpenSessions(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "open-active",
|
|
Project: "activity",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
MessageCount: 2,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertMessages(t, d,
|
|
Message{
|
|
SessionID: "open-active",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Timestamp: "2026-05-01T10:00:00Z",
|
|
},
|
|
Message{
|
|
SessionID: "open-active",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Timestamp: "2026-05-01T10:20:00Z",
|
|
},
|
|
)
|
|
insertExportSession(t, d, Session{
|
|
ID: "closed-stale",
|
|
Project: "activity",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:10:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "activity"},
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.Equal(t, []string{"open-active"}, sessionExportRowIDs(first.Rows))
|
|
row := first.Rows[0]
|
|
assert.Equal(t, "2026-05-01T10:20:00Z", row.LastActivityAt)
|
|
require.NotNil(t, row.DurationSeconds, "open session duration_seconds")
|
|
assert.Equal(t, int64(1200), *row.DurationSeconds)
|
|
|
|
payload, err := d.decodeSessionExportCursor(first.NextCursor)
|
|
require.NoError(t, err, "decode cursor")
|
|
assert.Equal(t, "2026-05-01T10:20:00Z", payload.Watermark)
|
|
assert.Equal(t, "2026-05-01T10:20:00Z", payload.LastActivityAt)
|
|
|
|
second, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "activity"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "second page")
|
|
assert.Equal(t, []string{"closed-stale"}, sessionExportRowIDs(second.Rows))
|
|
}
|
|
|
|
func TestSessionSummaryExportOrdersLastActivityByParsedInstant(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "whole-second",
|
|
Project: "activity-sort",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertExportSession(t, d, Session{
|
|
ID: "fractional-later",
|
|
Project: "activity-sort",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T10:00:00.123Z"),
|
|
EndedAt: Ptr("2026-05-01T10:00:00.123Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "activity-sort"},
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.Equal(t, []string{"fractional-later"}, sessionExportRowIDs(first.Rows))
|
|
require.NotEmpty(t, first.NextCursor)
|
|
assert.Equal(t, "2026-05-01T10:00:00.123Z", first.Rows[0].LastActivityAt)
|
|
|
|
second, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "activity-sort"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "second page")
|
|
require.Equal(t, []string{"whole-second"}, sessionExportRowIDs(second.Rows))
|
|
assert.Equal(t, "2026-05-01T10:00:00Z", second.Rows[0].LastActivityAt)
|
|
}
|
|
|
|
func TestSessionSummaryExportClosedSessionActivityPrefersEndedAt(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "closed-with-late-message",
|
|
Project: "activity",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:10:00Z"),
|
|
MessageCount: 2,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertMessages(t, d,
|
|
Message{
|
|
SessionID: "closed-with-late-message",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Timestamp: "2026-05-01T09:00:00Z",
|
|
},
|
|
Message{
|
|
SessionID: "closed-with-late-message",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Timestamp: "2026-05-01T10:30:00Z",
|
|
},
|
|
)
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "activity"},
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
require.Len(t, result.Rows, 1)
|
|
row := result.Rows[0]
|
|
assert.Equal(t, "2026-05-01T10:10:00Z", row.LastActivityAt)
|
|
require.NotNil(t, row.DurationSeconds, "duration_seconds")
|
|
assert.Equal(t, int64(4200), *row.DurationSeconds)
|
|
}
|
|
|
|
func TestSessionSummaryExportActiveSinceUsesMessageAwareActivity(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "open-active-after-cutoff",
|
|
Project: "activity-filter",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
MessageCount: 2,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertMessages(t, d,
|
|
Message{
|
|
SessionID: "open-active-after-cutoff",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Timestamp: "2026-05-01T09:00:00Z",
|
|
},
|
|
Message{
|
|
SessionID: "open-active-after-cutoff",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Timestamp: "2026-05-01T10:30:00Z",
|
|
},
|
|
)
|
|
insertExportSession(t, d, Session{
|
|
ID: "open-inactive-before-cutoff",
|
|
Project: "activity-filter",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{
|
|
Project: "activity-filter",
|
|
ActiveSince: "2026-05-01T10:00:00Z",
|
|
},
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
require.Equal(t, []string{"open-active-after-cutoff"}, sessionExportRowIDs(result.Rows))
|
|
assert.Equal(t, "2026-05-01T10:30:00Z", result.Rows[0].LastActivityAt)
|
|
}
|
|
|
|
func TestSessionSummaryExportActiveSinceIncludeChildrenKeepsOlderChildrenOfActiveRoot(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "active-root",
|
|
Project: "activity-tree",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
MessageCount: 2,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertMessages(t, d,
|
|
Message{
|
|
SessionID: "active-root",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Timestamp: "2026-05-01T09:00:00Z",
|
|
},
|
|
Message{
|
|
SessionID: "active-root",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Timestamp: "2026-05-01T10:30:00Z",
|
|
},
|
|
)
|
|
insertExportSession(t, d, Session{
|
|
ID: "older-child",
|
|
Project: "activity-tree",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:10:00Z"),
|
|
EndedAt: Ptr("2026-05-01T09:20:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
ParentSessionID: Ptr("active-root"),
|
|
RelationshipType: "subagent",
|
|
})
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{
|
|
Project: "activity-tree",
|
|
ActiveSince: "2026-05-01T10:00:00Z",
|
|
IncludeChildren: true,
|
|
},
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
assert.Equal(t, []string{"active-root", "older-child"},
|
|
sessionExportRowIDs(result.Rows))
|
|
}
|
|
|
|
func TestSessionSummaryExportActiveSinceIncludeChildrenDoesNotPromoteActiveChildOfInactiveRoot(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "inactive-root",
|
|
Project: "activity-tree",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertExportSession(t, d, Session{
|
|
ID: "active-child",
|
|
Project: "activity-tree",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T09:10:00Z"),
|
|
MessageCount: 2,
|
|
UserMessageCount: 1,
|
|
ParentSessionID: Ptr("inactive-root"),
|
|
RelationshipType: "subagent",
|
|
})
|
|
insertMessages(t, d,
|
|
Message{
|
|
SessionID: "active-child",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Timestamp: "2026-05-01T09:10:00Z",
|
|
},
|
|
Message{
|
|
SessionID: "active-child",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Timestamp: "2026-05-01T10:30:00Z",
|
|
},
|
|
)
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{
|
|
Project: "activity-tree",
|
|
ActiveSince: "2026-05-01T10:00:00Z",
|
|
IncludeChildren: true,
|
|
},
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
assert.Empty(t, result.Rows)
|
|
}
|
|
|
|
func TestSessionSummaryExportDoesNotFallbackWorktreeWithoutPathMatch(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
RootPath: "/repo/alpha/worktrees/feature",
|
|
GitRemote: "https://github.com/acme/alpha.git",
|
|
GitRemoteName: "origin",
|
|
WorktreeName: "feature",
|
|
WorktreeRootPath: "/repo/alpha/worktrees/feature",
|
|
ObservedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
|
}), "upsert project identity")
|
|
insertExportSession(t, d, Session{
|
|
ID: "outside-worktree",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
Cwd: "/tmp/outside-alpha",
|
|
StartedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:01:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
result, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
require.Len(t, result.Rows, 1)
|
|
assert.Nil(t, result.Rows[0].Worktree)
|
|
}
|
|
|
|
func TestSessionSummaryExportDefaultLimitIsMaxSessionLimit(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
for i := range MaxSessionLimit + 1 {
|
|
insertExportSession(t, d, Session{
|
|
ID: "default-limit-" + sortableInt(i),
|
|
Project: "limit",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
|
|
result, err := d.ExportSessionSummaries(context.Background(), SessionExportOptions{})
|
|
require.NoError(t, err, "ExportSessionSummaries")
|
|
assert.Len(t, result.Rows, MaxSessionLimit)
|
|
assert.NotEmpty(t, result.NextCursor)
|
|
}
|
|
|
|
func TestSessionExportCursorEmbedsSnapshotAndPaginatesStably(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.SetDatabaseIDForTest(ctx, "cursor-db"),
|
|
"set database id")
|
|
|
|
for _, row := range []struct {
|
|
id, ended string
|
|
}{
|
|
{"page-a", "2026-05-01T10:00:00Z"},
|
|
{"page-b", "2026-05-01T09:00:00Z"},
|
|
{"page-c", "2026-05-01T08:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
insertExportSession(t, d, Session{
|
|
ID: "other-project",
|
|
Project: "beta",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T07:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Limit: 2,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.Equal(t, []string{"page-a", "page-b"}, sessionExportRowIDs(first.Rows))
|
|
require.NotEmpty(t, first.NextCursor)
|
|
|
|
payload, err := d.decodeSessionExportCursor(first.NextCursor)
|
|
require.NoError(t, err, "decode export cursor")
|
|
assert.Equal(t, "cursor-db", payload.DatabaseID)
|
|
assert.Equal(t, "alpha", payload.Filters.Project)
|
|
assert.Equal(t, "last_activity_at DESC, id ASC", payload.Order)
|
|
assert.NotEmpty(t, payload.Watermark)
|
|
assert.Equal(t, "2026-05-01T09:00:00Z", payload.LastActivityAt)
|
|
assert.Equal(t, "page-b", payload.LastID)
|
|
assert.Equal(t, 2, payload.Limit)
|
|
assert.Equal(t, 3, payload.SnapshotCount)
|
|
assert.NotEmpty(t, payload.SnapshotDigest)
|
|
assert.Equal(t, 2, payload.PrefixCount)
|
|
assert.NotEmpty(t, payload.PrefixDigest)
|
|
|
|
second, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 10,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "second page")
|
|
require.Equal(t, []string{"page-c"}, sessionExportRowIDs(second.Rows))
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "insert-under-watermark-after-cursor",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T08:30:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertExportSession(t, d, Session{
|
|
ID: "insert-above-watermark",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2099-01-01T00:00:00Z"),
|
|
EndedAt: Ptr("2099-01-01T00:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
_, err = d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 10,
|
|
Format: "json",
|
|
})
|
|
require.Error(t, err, "changed watermarked set should reset cursor")
|
|
assert.True(t, errors.Is(err, ErrSessionExportCursorReset),
|
|
"expected reset error, got %v", err)
|
|
}
|
|
|
|
func TestSessionExportCursorPrefixUsesSameSnapshotAsPageQuery(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
|
|
for _, row := range []struct {
|
|
id, ended string
|
|
}{
|
|
{"page-a", "2026-05-01T10:00:00Z"},
|
|
{"page-b", "2026-05-01T09:00:00Z"},
|
|
{"page-c", "2026-05-01T08:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "snapshot",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
|
|
where, args := buildSessionExportFilter(SessionFilter{Project: "snapshot"})
|
|
tx, err := d.getReader().BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
|
require.NoError(t, err, "begin read snapshot")
|
|
defer func() { require.NoError(t, tx.Rollback(), "rollback read snapshot") }()
|
|
|
|
_, watermarkSort, err := d.sessionExportWatermark(ctx, tx, where, args)
|
|
require.NoError(t, err, "snapshot watermark")
|
|
rows, err := d.querySessionExportRows(
|
|
ctx, tx, where, args, watermarkSort, sessionExportCursorPayload{}, 2)
|
|
require.NoError(t, err, "snapshot page")
|
|
require.Len(t, rows, 3, "page query returns limit plus one")
|
|
emittedRows := rows[:2]
|
|
require.Equal(t, []string{"page-a", "page-b"},
|
|
sessionExportRowIDs(emittedRows))
|
|
|
|
last := emittedRows[len(emittedRows)-1]
|
|
snapshotCount, snapshotDigest, err := d.sessionExportPrefixFingerprint(
|
|
ctx, tx, where, args, watermarkSort, last.lastActivitySort, last.ID)
|
|
require.NoError(t, err, "snapshot prefix before mutation")
|
|
require.Equal(t, 2, snapshotCount)
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "page-c",
|
|
Project: "snapshot",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T09:30:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
|
|
liveCount, _, err := d.sessionExportPrefixFingerprint(
|
|
ctx, d.getReader(), where, args, watermarkSort, last.lastActivitySort, last.ID)
|
|
require.NoError(t, err, "live prefix after mutation")
|
|
assert.Equal(t, 3, liveCount,
|
|
"live reads now include the row that moved before the emitted cursor")
|
|
|
|
afterCount, afterDigest, err := d.sessionExportPrefixFingerprint(
|
|
ctx, tx, where, args, watermarkSort, last.lastActivitySort, last.ID)
|
|
require.NoError(t, err, "snapshot prefix after mutation")
|
|
assert.Equal(t, snapshotCount, afterCount)
|
|
assert.Equal(t, snapshotDigest, afterDigest)
|
|
}
|
|
|
|
func TestSessionExportUsageUsesPageReadSnapshot(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
seedSessionExportPricing(t, d)
|
|
insertExportSession(t, d, Session{
|
|
ID: "usage-snapshot", Project: "alpha", Machine: "local",
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "usage-snapshot", Ordinal: 0, Role: "assistant",
|
|
Timestamp: "2026-05-01T10:00:00Z", Model: "model-computed",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":100,"output_tokens":10}`),
|
|
HasContextTokens: true,
|
|
HasOutputTokens: true,
|
|
})
|
|
|
|
tx, err := d.getReader().BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
|
require.NoError(t, err, "begin page snapshot")
|
|
defer func() { require.NoError(t, tx.Rollback(), "rollback page snapshot") }()
|
|
var messageCount int
|
|
require.NoError(t, tx.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM messages WHERE session_id = ?`,
|
|
"usage-snapshot").Scan(&messageCount), "establish page snapshot")
|
|
require.Equal(t, 1, messageCount)
|
|
|
|
_, err = d.getWriter().ExecContext(ctx, `
|
|
UPDATE messages
|
|
SET token_usage = '{"input_tokens":900,"output_tokens":90}'
|
|
WHERE session_id = ?`, "usage-snapshot")
|
|
require.NoError(t, err, "mutate live usage after page snapshot")
|
|
|
|
rows := []SessionSummaryRow{{ID: "usage-snapshot"}}
|
|
_, err = d.attachSessionExportUsage(ctx, tx, rows)
|
|
require.NoError(t, err, "attach usage from page snapshot")
|
|
require.NotNil(t, rows[0].ModelUsage)
|
|
assert.Equal(t, 100, rows[0].ModelUsage.InputTokens)
|
|
assert.Equal(t, 10, rows[0].ModelUsage.OutputTokens)
|
|
}
|
|
|
|
func TestAllSessionExportKeepsOnePricingSnapshotAcrossPages(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
|
ModelPattern: "snapshot-model", InputPerMTok: 1,
|
|
}}))
|
|
for i, endedAt := range []string{
|
|
"2026-05-01T10:00:00Z", "2026-05-01T09:00:00Z",
|
|
} {
|
|
id := fmt.Sprintf("pricing-page-%d", i)
|
|
insertExportSession(t, d, Session{
|
|
ID: id, Project: "alpha", Machine: "local",
|
|
EndedAt: &endedAt, UserMessageCount: 1,
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: id, Ordinal: 0, Role: "assistant",
|
|
Timestamp: endedAt, Model: "snapshot-model",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000000}`),
|
|
HasContextTokens: true,
|
|
})
|
|
}
|
|
|
|
pages, err := d.exportAllSessionSummaries(ctx, SessionExportOptions{
|
|
Limit: 1, Filter: SessionFilter{IncludeChildren: true},
|
|
}, func(page int) error {
|
|
if page != 1 {
|
|
return nil
|
|
}
|
|
return d.UpsertModelPricing([]ModelPricing{{
|
|
ModelPattern: "snapshot-model", InputPerMTok: 99,
|
|
}})
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, pages, 2)
|
|
require.NotEmpty(t, pages[0].NextCursor)
|
|
internalCursor, err := d.decodeSessionExportCursor(pages[0].NextCursor)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, internalCursor.SnapshotCount)
|
|
assert.Empty(t, internalCursor.SnapshotDigest)
|
|
assert.Zero(t, internalCursor.PrefixCount)
|
|
assert.Empty(t, internalCursor.PrefixDigest)
|
|
require.NotNil(t, pages[0].Pricing)
|
|
require.NotNil(t, pages[1].Pricing)
|
|
assert.Equal(t, pages[0].Pricing.Digest, pages[1].Pricing.Digest)
|
|
for _, page := range pages {
|
|
require.Len(t, page.Rows, 1)
|
|
require.NotNil(t, page.Rows[0].ModelUsage)
|
|
assert.InDelta(t, 1.0, page.Rows[0].ModelUsage.CostUSD, 1e-9)
|
|
}
|
|
}
|
|
|
|
func TestAllSessionExportHonorsCancellation(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := d.ExportAllSessionSummaries(ctx, SessionExportOptions{Limit: 1})
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
}
|
|
|
|
func TestSessionExportCursorResetsWhenRowMovesBeforeCursor(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.SetDatabaseIDForTest(ctx, "cursor-db"),
|
|
"set database id")
|
|
|
|
for _, row := range []struct {
|
|
id, ended string
|
|
}{
|
|
{"page-a", "2026-05-01T10:00:00Z"},
|
|
{"page-b", "2026-05-01T09:00:00Z"},
|
|
{"page-c", "2026-05-01T08:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Limit: 2,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.Equal(t, []string{"page-a", "page-b"}, sessionExportRowIDs(first.Rows))
|
|
require.NotEmpty(t, first.NextCursor)
|
|
|
|
insertExportSession(t, d, Session{
|
|
ID: "page-c",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T09:30:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
_, err = d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 10,
|
|
Format: "json",
|
|
})
|
|
require.Error(t, err, "moved row should reset cursor")
|
|
assert.True(t, errors.Is(err, ErrSessionExportCursorReset),
|
|
"expected reset error, got %v", err)
|
|
}
|
|
|
|
func TestSessionExportCursorResetsWhenUnemittedRowMovesAboveWatermark(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.SetDatabaseIDForTest(ctx, "cursor-db"),
|
|
"set database id")
|
|
|
|
for _, row := range []struct {
|
|
id, ended string
|
|
}{
|
|
{"page-a", "2026-05-01T10:00:00Z"},
|
|
{"page-b", "2026-05-01T09:00:00Z"},
|
|
{"page-c", "2026-05-01T08:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "watermark-reset",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "watermark-reset"},
|
|
Limit: 1,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.Equal(t, []string{"page-a"}, sessionExportRowIDs(first.Rows))
|
|
require.NotEmpty(t, first.NextCursor)
|
|
|
|
_, err = d.getWriter().ExecContext(ctx,
|
|
`UPDATE sessions SET ended_at = ? WHERE id = ?`,
|
|
"2026-05-01T11:00:00Z", "page-c",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "watermark-reset"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
Format: "json",
|
|
})
|
|
require.Error(t, err, "moved suffix row should reset cursor")
|
|
assert.True(t, errors.Is(err, ErrSessionExportCursorReset),
|
|
"expected reset error, got %v", err)
|
|
}
|
|
|
|
func TestSessionExportCursorRejectsWrongDatabaseAndChangedFilters(t *testing.T) {
|
|
ctx := context.Background()
|
|
d1 := testDB(t)
|
|
d2 := testDB(t)
|
|
require.NoError(t, d1.SetDatabaseIDForTest(ctx, "db-one"), "set db one")
|
|
require.NoError(t, d2.SetDatabaseIDForTest(ctx, "db-two"), "set db two")
|
|
// Two archives on the same install share the config cursor_secret; the
|
|
// wrong-database reset path applies to that case. Cursors from another
|
|
// install fail signature verification instead.
|
|
sharedSecret := []byte("session-export-shared-install-secret")
|
|
d1.SetCursorSecret(sharedSecret)
|
|
d2.SetCursorSecret(sharedSecret)
|
|
for _, d := range []*DB{d1, d2} {
|
|
insertExportSession(t, d, Session{
|
|
ID: "cursor-reset-row",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T10:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
insertExportSession(t, d, Session{
|
|
ID: "cursor-reset-row-2",
|
|
Project: "alpha",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr("2026-05-01T09:00:00Z"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
first, err := d1.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Limit: 1,
|
|
Format: "json",
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.NotEmpty(t, first.NextCursor)
|
|
|
|
_, err = d2.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
Format: "json",
|
|
})
|
|
require.Error(t, err, "wrong database cursor")
|
|
assert.True(t, errors.Is(err, ErrSessionExportCursorReset),
|
|
"expected reset error, got %v", err)
|
|
|
|
_, err = d1.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "beta"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
Format: "json",
|
|
})
|
|
require.Error(t, err, "changed filter cursor")
|
|
assert.True(t, errors.Is(err, ErrSessionExportCursorConflict),
|
|
"expected conflict error, got %v", err)
|
|
|
|
formatChanged, err := d1.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "alpha"},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
Format: "human",
|
|
})
|
|
require.NoError(t, err, "changed format should not affect cursor identity")
|
|
assert.Equal(t, []string{"cursor-reset-row-2"}, sessionExportRowIDs(formatChanged.Rows))
|
|
}
|
|
|
|
func TestSessionExportCursorAllowsEquivalentFilters(t *testing.T) {
|
|
d := testSessionExportDB(t)
|
|
ctx := context.Background()
|
|
for _, row := range []struct {
|
|
id, machine, outcome, ended string
|
|
}{
|
|
{"filter-a", "local", "success", "2026-05-01T10:00:00Z"},
|
|
{"filter-b", "laptop", "failed", "2026-05-01T09:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "filter",
|
|
Machine: row.machine,
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
Outcome: row.outcome,
|
|
HealthGrade: Ptr("A"),
|
|
})
|
|
require.NoError(t, d.UpdateSessionSignals(row.id, SessionSignalUpdate{
|
|
Outcome: row.outcome,
|
|
HealthGrade: Ptr("A"),
|
|
}), "update filter signals %s", row.id)
|
|
}
|
|
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{
|
|
Project: "filter",
|
|
Machine: " local, laptop ",
|
|
Outcome: []string{"success", "failed"},
|
|
HealthGrade: []string{"A", "B"},
|
|
ExcludeAutomated: true,
|
|
},
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.NotEmpty(t, first.NextCursor)
|
|
|
|
second, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{
|
|
Project: "filter",
|
|
Machine: "laptop,local",
|
|
Outcome: []string{"failed", "success"},
|
|
HealthGrade: []string{"B", "A"},
|
|
AutomatedScope: "human",
|
|
},
|
|
Cursor: first.NextCursor,
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "equivalent filters should not conflict")
|
|
assert.Equal(t, []string{"filter-b"}, sessionExportRowIDs(second.Rows))
|
|
}
|
|
|
|
func TestSessionExportCursorTamperingReturnsInvalidCursor(t *testing.T) {
|
|
ctx := context.Background()
|
|
d := testSessionExportDB(t)
|
|
require.NoError(t, d.SetDatabaseIDForTest(ctx, "tamper-db"), "set database id")
|
|
for _, row := range []struct {
|
|
id, ended string
|
|
}{
|
|
{"tamper-a", "2026-05-01T10:00:00Z"},
|
|
{"tamper-b", "2026-05-01T09:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "tamper",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "tamper"},
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
|
|
_, err = d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "tamper"},
|
|
Cursor: tamperSessionExportCursorDatabaseID(t, first.NextCursor, "other-db"),
|
|
Limit: 1,
|
|
})
|
|
require.Error(t, err, "tampered cursor")
|
|
assert.True(t, errors.Is(err, ErrInvalidCursor),
|
|
"expected invalid cursor, got %v", err)
|
|
assert.False(t, errors.Is(err, ErrSessionExportCursorReset),
|
|
"tampered cursor must not be treated as a valid wrong-database cursor")
|
|
}
|
|
|
|
func TestSessionExportCursorRejectsForgeryWithKnownKey(t *testing.T) {
|
|
ctx := context.Background()
|
|
d := testSessionExportDB(t)
|
|
require.NoError(t, d.SetDatabaseIDForTest(ctx, "forge-db"), "set database id")
|
|
for _, row := range []struct {
|
|
id, ended string
|
|
}{
|
|
{"forge-a", "2026-05-01T10:00:00Z"},
|
|
{"forge-b", "2026-05-01T09:00:00Z"},
|
|
} {
|
|
insertExportSession(t, d, Session{
|
|
ID: row.id,
|
|
Project: "forge",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
StartedAt: Ptr("2026-05-01T00:00:00Z"),
|
|
EndedAt: Ptr(row.ended),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
})
|
|
}
|
|
first, err := d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Filter: SessionFilter{Project: "forge"},
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err, "first page")
|
|
require.NotEmpty(t, first.NextCursor, "next cursor")
|
|
|
|
// A consumer holding a cursor must not be able to widen its scope by
|
|
// rewriting the payload and re-signing with a publicly known key, such
|
|
// as the constant this cursor was signed with before per-install
|
|
// secrets.
|
|
parts := strings.Split(first.NextCursor, ".")
|
|
require.Len(t, parts, 2, "cursor parts")
|
|
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
require.NoError(t, err, "decode cursor payload")
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &payload), "unmarshal payload")
|
|
payload["filters"] = map[string]any{}
|
|
forged, err := json.Marshal(payload)
|
|
require.NoError(t, err, "marshal forged payload")
|
|
mac := hmac.New(sha256.New, []byte("agentsview-session-summary-export-v1"))
|
|
mac.Write(forged)
|
|
forgedCursor := base64.RawURLEncoding.EncodeToString(forged) + "." +
|
|
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
_, err = d.ExportSessionSummaries(ctx, SessionExportOptions{
|
|
Cursor: forgedCursor,
|
|
UseCursorFilter: true,
|
|
Limit: 1,
|
|
})
|
|
require.Error(t, err, "forged cursor")
|
|
assert.True(t, errors.Is(err, ErrInvalidCursor),
|
|
"expected invalid cursor, got %v", err)
|
|
}
|
|
|
|
func testSessionExportDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
d := testDB(t)
|
|
require.NoError(t, d.SetDatabaseIDForTest(
|
|
context.Background(), "session-export-db"),
|
|
"set session export database id")
|
|
return d
|
|
}
|
|
|
|
func seedSessionExportPricing(t *testing.T, d *DB) {
|
|
t.Helper()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{
|
|
{
|
|
ModelPattern: "model-computed",
|
|
InputPerMTok: 10,
|
|
OutputPerMTok: 20,
|
|
CacheCreationPerMTok: 30,
|
|
CacheReadPerMTok: 1,
|
|
},
|
|
{
|
|
ModelPattern: "model-reported",
|
|
InputPerMTok: 1,
|
|
OutputPerMTok: 2,
|
|
CacheCreationPerMTok: 3,
|
|
CacheReadPerMTok: 0.5,
|
|
},
|
|
{
|
|
ModelPattern: "unreturned-model",
|
|
InputPerMTok: 100,
|
|
OutputPerMTok: 200,
|
|
CacheCreationPerMTok: 300,
|
|
CacheReadPerMTok: 50,
|
|
},
|
|
}), "upsert pricing")
|
|
}
|
|
|
|
func insertExportSession(t *testing.T, d *DB, s Session) {
|
|
t.Helper()
|
|
if s.Machine == "" {
|
|
s.Machine = defaultMachine
|
|
}
|
|
if s.Agent == "" {
|
|
s.Agent = defaultAgent
|
|
}
|
|
if s.MessageCount == 0 {
|
|
s.MessageCount = 1
|
|
}
|
|
require.NoError(t, d.UpsertSession(s), "upsert export session %s", s.ID)
|
|
}
|
|
|
|
func sessionExportRowsByID(rows []SessionSummaryRow) map[string]SessionSummaryRow {
|
|
out := make(map[string]SessionSummaryRow, len(rows))
|
|
for _, row := range rows {
|
|
out[row.ID] = row
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sessionExportRowIDs(rows []SessionSummaryRow) []string {
|
|
out := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, row.ID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortedSetKeysFromMap[V any](m map[string]V) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func tamperSessionExportCursorDatabaseID(
|
|
t *testing.T, cursor, databaseID string,
|
|
) string {
|
|
t.Helper()
|
|
parts := strings.Split(cursor, ".")
|
|
require.Len(t, parts, 2, "cursor parts")
|
|
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
require.NoError(t, err, "decode cursor payload")
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &payload), "unmarshal cursor payload")
|
|
payload["database_id"] = databaseID
|
|
tampered, err := json.Marshal(payload)
|
|
require.NoError(t, err, "marshal tampered cursor payload")
|
|
return base64.RawURLEncoding.EncodeToString(tampered) + "." + parts[1]
|
|
}
|
|
|
|
func assertJSONHasKey(t *testing.T, v any, key string) {
|
|
t.Helper()
|
|
data, err := json.Marshal(v)
|
|
require.NoError(t, err, "marshal value")
|
|
var obj map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &obj), "unmarshal object")
|
|
assert.Contains(t, obj, key)
|
|
}
|
|
|
|
func assertContentFreeJSON(t *testing.T, v any) {
|
|
t.Helper()
|
|
data, err := json.Marshal(v)
|
|
require.NoError(t, err, "marshal content-free JSON")
|
|
var decoded any
|
|
require.NoError(t, json.Unmarshal(data, &decoded), "unmarshal content-free JSON")
|
|
assertNoBannedJSONKeys(t, decoded)
|
|
}
|
|
|
|
func assertNoBannedJSONKeys(t *testing.T, v any) {
|
|
t.Helper()
|
|
banned := map[string]struct{}{
|
|
"message": {}, "messages": {}, "prompt": {}, "response": {},
|
|
"content": {}, "text": {}, "transcript": {},
|
|
}
|
|
switch x := v.(type) {
|
|
case map[string]any:
|
|
for k, child := range x {
|
|
if _, ok := banned[k]; ok {
|
|
assert.Failf(t, "banned JSON key", "found key %q", k)
|
|
}
|
|
assertNoBannedJSONKeys(t, child)
|
|
}
|
|
case []any:
|
|
for _, child := range x {
|
|
assertNoBannedJSONKeys(t, child)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortableInt(v int) string {
|
|
return string(rune('a'+(v/100)%10)) +
|
|
string(rune('a'+(v/10)%10)) +
|
|
string(rune('a'+v%10))
|
|
}
|