Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

486 lines
17 KiB
Go

package parser
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// coworkProviderForRoot constructs a cowork provider rooted at root.
func coworkProviderForRoot(t *testing.T, root, machine string) Provider {
t.Helper()
provider, ok := NewProvider(AgentCowork, ProviderConfig{
Roots: []string{root},
Machine: machine,
})
require.True(t, ok)
return provider
}
// coworkDiscoveredPaths returns the transcript paths the provider discovers
// under root.
func coworkDiscoveredPaths(t *testing.T, root string) []string {
t.Helper()
sources, err := coworkProviderForRoot(t, root, "").Discover(context.Background())
require.NoError(t, err)
paths := make([]string, len(sources))
for i, source := range sources {
paths[i] = source.DisplayPath
}
return paths
}
// coworkParseTranscript finds and parses a single cowork transcript through
// the provider, returning the parse results and any excluded session IDs.
func coworkParseTranscript(
t *testing.T, root, transcript, machine string,
) ([]ParseResult, []string) {
t.Helper()
provider := coworkProviderForRoot(t, root, machine)
source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
StoredFilePath: transcript,
})
require.NoError(t, err)
require.True(t, ok, "find source for %s", transcript)
outcome, err := provider.Parse(context.Background(), ParseRequest{
Source: source,
Machine: machine,
})
require.NoError(t, err)
results := make([]ParseResult, len(outcome.Results))
for i, out := range outcome.Results {
results[i] = out.Result
}
return results, outcome.ExcludedSessionIDs
}
// All identifiers, titles, and content below are synthetic fixtures.
// coworkFixture describes one cowork session to materialize on disk.
type coworkFixture struct {
org string
workspace string
sessionUUID string // names local_<uuid>.json and local_<uuid>/
cliSessionID string // names the nested transcript
encodedProject string // the .claude/projects/<enc> directory name
title string
folders []string
createdAt int64
lastActivityAt int64
transcriptLines []string
}
// writeCoworkSession materializes a cowork session under root and returns
// the metadata path and transcript path.
func writeCoworkSession(
t *testing.T, root string, f coworkFixture,
) (metaPath, transcriptPath string) {
t.Helper()
wsDir := filepath.Join(root, f.org, f.workspace)
sessionDirName := "local_" + f.sessionUUID
metaPath = filepath.Join(wsDir, sessionDirName+".json")
meta := map[string]any{
"sessionId": sessionDirName,
"cliSessionId": f.cliSessionID,
"title": f.title,
"userSelectedFolders": f.folders,
"createdAt": f.createdAt,
"lastActivityAt": f.lastActivityAt,
}
metaBytes, err := json.Marshal(meta)
require.NoError(t, err, "marshal meta")
require.NoError(t, os.MkdirAll(wsDir, 0o755), "mkdir workspace")
require.NoError(t, os.WriteFile(metaPath, metaBytes, 0o644), "write meta")
projectDir := filepath.Join(
wsDir, sessionDirName, ".claude", "projects", f.encodedProject,
)
require.NoError(t, os.MkdirAll(projectDir, 0o755), "mkdir project")
transcriptPath = filepath.Join(projectDir, f.cliSessionID+".jsonl")
require.NoError(t,
os.WriteFile(
transcriptPath,
[]byte(strings.Join(f.transcriptLines, "\n")+"\n"),
0o644,
),
"write transcript",
)
return metaPath, transcriptPath
}
// coworkTranscriptLines returns a small but realistic Claude Code
// transcript: an ai-title event, one user turn, and one assistant turn
// carrying token usage.
func coworkTranscriptLines(cli string) []string {
return []string{
`{"type":"ai-title","aiTitle":"Auto title","sessionId":"` + cli + `"}`,
`{"type":"user","uuid":"u1","parentUuid":null,` +
`"sessionId":"` + cli + `","cwd":"/sessions/test",` +
`"gitBranch":"HEAD","version":"2.1.119",` +
`"timestamp":"2026-03-01T10:00:00.000Z",` +
`"message":{"role":"user","content":"hello there"}}`,
`{"type":"assistant","uuid":"a1","parentUuid":"u1",` +
`"sessionId":"` + cli + `","requestId":"req_1",` +
`"timestamp":"2026-03-01T10:00:05.000Z",` +
`"message":{"role":"assistant","id":"msg_1",` +
`"model":"claude-sonnet-4-6","stop_reason":"end_turn",` +
`"content":[{"type":"text","text":"hi back"}],` +
`"usage":{"input_tokens":10,"cache_read_input_tokens":2,` +
`"output_tokens":5}}}`,
}
}
func TestCoworkProviderDiscoversSessions(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000001"
_, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000001",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "Demo session",
transcriptLines: coworkTranscriptLines(cli),
})
got := coworkDiscoveredPaths(t, root)
require.Len(t, got, 1, "discovered sources")
assert.Equal(t, transcript, got[0], "DisplayPath")
}
func TestCoworkProviderDiscoverIgnoresNoise(t *testing.T) {
root := t.TempDir()
wsDir := filepath.Join(root, "org", "ws")
require.NoError(t, os.MkdirAll(wsDir, 0o755), "mkdir ws")
// Sibling cache files must not be treated as session metadata.
for _, name := range []string{
"cowork_settings.json",
"cowork-clientdata-cache.json",
"artifacts.json",
} {
require.NoError(t,
os.WriteFile(filepath.Join(wsDir, name), []byte("{}"), 0o644),
"write %s", name,
)
}
// A skills-plugin mirror must be skipped entirely.
skillDir := filepath.Join(root, "skills-plugin", "ws", "org")
require.NoError(t, os.MkdirAll(skillDir, 0o755), "mkdir skills")
require.NoError(t,
os.WriteFile(
filepath.Join(skillDir, "local_fake.json"), []byte("{}"), 0o644,
),
"write skills noise",
)
// A metadata file with no transcript yet must be skipped.
require.NoError(t,
os.WriteFile(
filepath.Join(wsDir, "local_"+
"00000000-0000-4000-8000-0000000000ff.json"),
[]byte(`{"cliSessionId":"00000000-0000-4000-8000-0000000000fe"}`), 0o644,
),
"write transcript-less meta",
)
assert.Empty(t, coworkDiscoveredPaths(t, root))
}
func TestCoworkProviderParsesSession(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000002"
_, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000002",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "Sample session title",
createdAt: 1700000000000,
lastActivityAt: 1700000100000,
transcriptLines: coworkTranscriptLines(cli),
})
results, excluded := coworkParseTranscript(t, root, transcript, "host-1")
require.Empty(t, excluded, "excluded")
require.Len(t, results, 1, "results")
sess := results[0].Session
assert.Equal(t, "cowork:"+cli, sess.ID, "ID prefixed")
assert.Equal(t, AgentCowork, sess.Agent, "Agent")
assert.Equal(t, "cowork", sess.Project, "Project")
assert.Equal(t, "Sample session title", sess.SessionName, "title")
assert.Equal(t, "host-1", sess.Machine, "Machine")
assert.Equal(t, 2, sess.MessageCount, "MessageCount")
assert.Equal(t, 1, sess.UserMessageCount, "UserMessageCount")
// Token usage must be counted (this is the crux of issue #639).
hasTotal, hasPeak := sess.AggregateTokenPresence()
assert.True(t, hasTotal, "HasTotalOutputTokens")
assert.True(t, hasPeak, "HasPeakContextTokens")
assert.Equal(t, 5, sess.TotalOutputTokens, "TotalOutputTokens")
assert.Equal(t, 12, sess.PeakContextTokens, "PeakContextTokens (input+cacheRead)")
}
func TestCoworkProviderParseTitleFallsBackToAITitle(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000003"
_, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000003",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "", // no explicit title
transcriptLines: coworkTranscriptLines(cli),
})
results, _ := coworkParseTranscript(t, root, transcript, "host-1")
require.Len(t, results, 1, "results")
assert.Equal(t, "Auto title", results[0].Session.SessionName,
"falls back to ai-title event")
}
func TestCoworkProviderParseProjectFromSelectedFolder(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000004"
_, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000004",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "With folder",
folders: []string{"/home/user/code/my-app"},
transcriptLines: coworkTranscriptLines(cli),
})
results, _ := coworkParseTranscript(t, root, transcript, "host-1")
require.Len(t, results, 1, "results")
assert.Equal(t, "my_app", results[0].Session.Project,
"project derived from userSelectedFolders")
}
func TestCoworkProviderFindsSourceFile(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000005"
_, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000005",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "x",
transcriptLines: coworkTranscriptLines(cli),
})
provider := coworkProviderForRoot(t, root, "")
found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
RawSessionID: cli,
})
require.NoError(t, err)
require.True(t, ok, "found")
assert.Equal(t, transcript, found.DisplayPath)
_, ok, err = provider.FindSource(context.Background(), FindSourceRequest{
RawSessionID: "nonexistent-id",
})
require.NoError(t, err)
assert.False(t, ok, "missing")
}
func TestCoworkProviderClassifiesChangedPath(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000006"
metaPath, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000006",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "x",
transcriptLines: coworkTranscriptLines(cli),
})
provider := coworkProviderForRoot(t, root, "")
classify := func(path string) (string, bool) {
sources, err := provider.SourcesForChangedPath(
context.Background(),
ChangedPathRequest{Path: path, EventKind: "write", WatchRoot: root},
)
require.NoError(t, err)
if len(sources) == 0 {
return "", false
}
require.Len(t, sources, 1)
return sources[0].DisplayPath, true
}
// A transcript change classifies to itself.
got, ok := classify(transcript)
require.True(t, ok, "transcript classified")
assert.Equal(t, transcript, got, "transcript path")
// A metadata change resolves to the session's transcript.
got, ok = classify(metaPath)
require.True(t, ok, "metadata classified")
assert.Equal(t, transcript, got, "metadata resolves to transcript")
// Unrelated and outside-root paths are ignored.
_, ok = classify(filepath.Join(root, "org", "ws", "artifacts.json"))
assert.False(t, ok, "cache file ignored")
_, ok = classify("/some/other/place.jsonl")
assert.False(t, ok, "outside root ignored")
}
func TestCoworkSessionMtime(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000007"
metaPath, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000007",
cliSessionID: cli,
encodedProject: "-sessions-demo",
title: "Before",
transcriptLines: coworkTranscriptLines(cli),
})
tInfo, err := os.Stat(transcript)
require.NoError(t, err, "stat transcript")
tMtime := tInfo.ModTime().UnixNano()
// Baseline: with metadata older than the transcript, the transcript
// mtime wins.
older := tInfo.ModTime().Add(-time.Hour)
require.NoError(t, os.Chtimes(metaPath, older, older), "age metadata")
assert.Equal(t, tMtime, CoworkSessionMtime(transcript, tMtime),
"transcript mtime wins when metadata is older")
// A title rename bumps only the metadata file's mtime; the composite
// must reflect it so the session is re-parsed instead of skipped.
newer := tInfo.ModTime().Add(time.Hour)
require.NoError(t, os.Chtimes(metaPath, newer, newer), "touch metadata")
assert.Equal(t, newer.UnixNano(), CoworkSessionMtime(transcript, tMtime),
"metadata mtime folded into composite")
// No metadata file -> transcript mtime.
require.NoError(t, os.Remove(metaPath), "remove metadata")
assert.Equal(t, tMtime, CoworkSessionMtime(transcript, tMtime),
"transcript mtime when metadata missing")
}
func TestCoworkProviderDiscoverIncludesSubagents(t *testing.T) {
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000008"
enc := "-sessions-demo"
_, transcript := writeCoworkSession(t, root, coworkFixture{
org: "org",
workspace: "ws",
sessionUUID: "50000000-0000-4000-8000-000000000008",
cliSessionID: cli,
encodedProject: enc,
title: "Subagent demo",
transcriptLines: coworkTranscriptLines(cli),
})
// Write a subagent transcript alongside the main one, mirroring
// Claude Code's layout: <enc>/<cli>/subagents/agent-<id>.jsonl.
subDir := filepath.Join(filepath.Dir(transcript), cli, "subagents")
require.NoError(t, os.MkdirAll(subDir, 0o755), "mkdir subagents")
subPath := filepath.Join(subDir, "agent-0000000000000001.jsonl")
subLines := []string{
`{"type":"user","uuid":"su1","parentUuid":null,"sessionId":"` + cli + `",` +
`"cwd":"/sessions/test","timestamp":"2026-03-01T10:01:00.000Z",` +
`"message":{"role":"user","content":"sub task"}}`,
`{"type":"assistant","uuid":"sa1","parentUuid":"su1","sessionId":"` + cli + `",` +
`"timestamp":"2026-03-01T10:01:05.000Z","message":{"role":"assistant",` +
`"id":"m2","model":"claude-sonnet-4-6","stop_reason":"end_turn",` +
`"content":[{"type":"text","text":"done"}],` +
`"usage":{"input_tokens":3,"output_tokens":2}}}`,
}
require.NoError(t,
os.WriteFile(subPath, []byte(strings.Join(subLines, "\n")+"\n"), 0o644),
"write subagent",
)
paths := coworkDiscoveredPaths(t, root)
assert.Contains(t, paths, transcript, "main transcript discovered")
assert.Contains(t, paths, subPath, "subagent transcript discovered")
// The subagent parses into a cowork-namespaced subagent session whose
// parent is the main session.
results, _ := coworkParseTranscript(t, root, subPath, "host-1")
require.Len(t, results, 1, "results")
sub := results[0].Session
assert.Equal(t, "cowork:agent-0000000000000001", sub.ID, "subagent ID")
assert.Equal(t, "cowork:"+cli, sub.ParentSessionID, "parent prefixed")
assert.Equal(t, RelSubagent, sub.RelationshipType, "RelSubagent")
// The provider resolves the subagent by its raw ID too.
provider := coworkProviderForRoot(t, root, "")
found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
RawSessionID: "agent-0000000000000001",
})
require.NoError(t, err)
require.True(t, ok, "find subagent source")
assert.Equal(t, subPath, found.DisplayPath)
}
func TestResolveCoworkSessionRejectsSymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on Windows")
}
root := t.TempDir()
cli := "c0000000-0000-4000-8000-000000000009"
sessionDir := filepath.Join(root, "org", "ws", "local_session")
projectsDir := filepath.Join(sessionDir, ".claude", "projects")
require.NoError(t, os.MkdirAll(projectsDir, 0o755), "mkdir projects")
// Plant the real transcript OUTSIDE the session dir, then expose it
// inside projects/ via a directory symlink. resolveCoworkSession must
// refuse to follow the escape.
outside := filepath.Join(root, "outside")
require.NoError(t, os.MkdirAll(outside, 0o755), "mkdir outside")
require.NoError(t,
os.WriteFile(filepath.Join(outside, cli+".jsonl"), []byte("{}\n"), 0o644),
"write escaped transcript",
)
require.NoError(t,
os.Symlink(outside, filepath.Join(projectsDir, "-evil")),
"symlink enc dir",
)
main, encDir := resolveCoworkSession(sessionDir, cli)
assert.Empty(t, main, "symlinked escape rejected")
assert.Empty(t, encDir, "no enc dir for escape")
}
func TestCoworkDefaultDirs(t *testing.T) {
dirs := coworkDefaultDirs()
require.Len(t, dirs, 4, "macOS, Linux, Windows MSIX, Windows Roaming")
assert.Contains(t, dirs,
"AppData/Local/Packages/Claude_pzs8sxrjxfjjc/"+
"LocalCache/Roaming/Claude/local-agent-mode-sessions",
"Windows MSIX package-local path")
assert.Contains(t, dirs,
"AppData/Roaming/Claude/local-agent-mode-sessions",
"Windows Roaming fallback")
for _, d := range dirs {
assert.True(t,
strings.HasSuffix(d, "local-agent-mode-sessions"),
"dir %q targets local-agent-mode-sessions", d,
)
}
}