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
588 lines
28 KiB
Go
588 lines
28 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// writeOpenClawTestFile creates a test JSONL file inside an
|
|
// agent directory structure: <root>/<agentId>/sessions/<name>.jsonl.
|
|
// Returns the full path to the file and the root agents directory.
|
|
func writeOpenClawTestFile(
|
|
t *testing.T, agentID string, lines ...string,
|
|
) (path, agentsDir string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, agentID, "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
path = filepath.Join(sessDir, "test-session.jsonl")
|
|
var b strings.Builder
|
|
for _, line := range lines {
|
|
b.WriteString(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
content := b.String()
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
|
|
return path, root
|
|
}
|
|
|
|
func parseOpenClawSessionForTest(
|
|
t *testing.T,
|
|
path, project, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
t.Helper()
|
|
provider, ok := NewProvider(AgentOpenClaw, ProviderConfig{
|
|
Roots: []string{filepath.Dir(filepath.Dir(filepath.Dir(path)))},
|
|
Machine: machine,
|
|
})
|
|
require.True(t, ok)
|
|
claw, ok := provider.(*openClawProvider)
|
|
require.True(t, ok)
|
|
return claw.parseSession(path, project, machine)
|
|
}
|
|
|
|
func discoverOpenClawSessionsForTest(t *testing.T, root string) []SourceRef {
|
|
t.Helper()
|
|
provider, ok := NewProvider(AgentOpenClaw, ProviderConfig{Roots: []string{root}})
|
|
require.True(t, ok)
|
|
sources, err := provider.Discover(context.Background())
|
|
require.NoError(t, err)
|
|
return sources
|
|
}
|
|
|
|
func findOpenClawSourceForTest(t *testing.T, root, rawID string) string {
|
|
t.Helper()
|
|
provider, ok := NewProvider(AgentOpenClaw, ProviderConfig{Roots: []string{root}})
|
|
require.True(t, ok)
|
|
source, found, err := provider.FindSource(context.Background(), FindSourceRequest{
|
|
RawSessionID: rawID,
|
|
})
|
|
require.NoError(t, err)
|
|
if !found {
|
|
return ""
|
|
}
|
|
return source.DisplayPath
|
|
}
|
|
|
|
func TestParseOpenClawSession_Basic(t *testing.T) {
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"abc-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}`,
|
|
`{"type":"model_change","id":"mc1","timestamp":"2026-02-25T10:00:00Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello, how are you?"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"I'm doing well, thanks!"}],"timestamp":"2026-02-25T10:00:02Z"}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test-machine")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess, "expected session, got nil")
|
|
|
|
assert.Equal(t, "openclaw:main:abc-123", sess.ID, "expected ID openclaw:main:abc-123, got %s")
|
|
assert.Equal(t, AgentOpenClaw, sess.Agent, "expected agent openclaw, got %s")
|
|
assert.Equal(t, "test-machine", sess.Machine, "expected machine test-machine, got %s")
|
|
assert.Equal(t, "project", sess.Project, "expected project 'project', got %s")
|
|
assert.Equal(t, "Hello, how are you?", sess.FirstMessage, "expected first message 'Hello, how are you?', got %s")
|
|
require.Equal(t, 2, len(msgs), "expected 2 messages, got %d")
|
|
assert.Equal(t, RoleUser, msgs[0].Role, "expected first role user, got %s")
|
|
assert.Equal(t, RoleAssistant, msgs[1].Role, "expected second role assistant, got %s")
|
|
assert.Equal(t, 1, sess.UserMessageCount, "expected 1 user message, got %d")
|
|
}
|
|
|
|
func TestParseOpenClawSession_Thinking(t *testing.T) {
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"think-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Think about this"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me consider..."},{"type":"text","text":"Here is my response."}],"timestamp":"2026-02-25T10:00:02Z"}}`,
|
|
)
|
|
|
|
_, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, len(msgs), "expected 2 messages, got %d")
|
|
assert.True(t, msgs[1].HasThinking, "expected HasThinking=true for assistant message")
|
|
}
|
|
|
|
func TestParseOpenClawSession_ToolResult(t *testing.T) {
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"tool-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Read a file"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"read","input":{"path":"/etc/hosts"}}],"timestamp":"2026-02-25T10:00:02Z"}}`,
|
|
`{"type":"message","id":"m3","timestamp":"2026-02-25T10:00:03Z","message":{"role":"toolResult","toolCallId":"tu1","toolName":"read","content":[{"type":"text","text":"127.0.0.1 localhost"}],"isError":false,"timestamp":"2026-02-25T10:00:03Z"}}`,
|
|
`{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"The hosts file contains localhost."}],"timestamp":"2026-02-25T10:00:04Z"}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 4, len(msgs), "expected 4 messages, got %d")
|
|
// Assistant with tool_use
|
|
assert.True(t, msgs[1].HasToolUse, "expected HasToolUse=true for tool-use message")
|
|
require.Equal(t, 1, len(msgs[1].ToolCalls), "expected 1 tool call, got %d")
|
|
assert.Equal(t, "read", msgs[1].ToolCalls[0].ToolName, "expected tool name 'read', got %s")
|
|
assert.Equal(t, "Read", msgs[1].ToolCalls[0].Category, "expected category 'Read', got %s")
|
|
|
|
// Tool result mapped to user role
|
|
assert.Equal(t, RoleUser, msgs[2].Role, "expected tool result as user role, got %s")
|
|
require.Equal(t, 1, len(msgs[2].ToolResults), "expected 1 tool result, got %d")
|
|
assert.Equal(t, "tu1", msgs[2].ToolResults[0].ToolUseID, "expected tool use ID 'tu1', got %s")
|
|
assert.Equal(t, 4, sess.MessageCount, "expected 4 messages, got %d")
|
|
|
|
// UserMessageCount should only count the real user message,
|
|
// not the synthetic tool-result message.
|
|
assert.Equal(t, 1, sess.UserMessageCount, "expected UserMessageCount 1 (tool results excluded), got %d")
|
|
}
|
|
|
|
func TestParseOpenClawSession_RealToolCallFormat(t *testing.T) {
|
|
// Real OpenClaw output uses camelCase "toolCall" blocks for
|
|
// assistant tool calls and "toolResult" content blocks inside
|
|
// role:"toolResult" messages -- not the Anthropic snake_case
|
|
// "tool_use"/"tool_result" the shared extractor originally knew.
|
|
// Some tools (read/exec) populate only "arguments"; others (bash)
|
|
// duplicate it under "input".
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"real-tc","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"list files"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
// Assistant turn that is ONLY a toolCall (input + arguments duplicated).
|
|
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_1","name":"bash","input":{"command":"ls"},"arguments":{"command":"ls"}}],"timestamp":"2026-02-25T10:00:02Z"}}`,
|
|
// Tool result as a standalone toolResult-role message carrying a toolResult content block.
|
|
`{"type":"message","id":"m3","timestamp":"2026-02-25T10:00:03Z","message":{"role":"toolResult","toolCallId":"call_1","toolName":"bash","isError":false,"content":[{"type":"toolResult","toolCallId":"call_1","tool_use_id":"call_1","name":"bash","text":"file1\nfile2"}],"timestamp":"2026-02-25T10:00:03Z"}}`,
|
|
// Second toolCall where args live ONLY under "arguments".
|
|
`{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_2","name":"read","arguments":{"path":"/etc/hosts"}}],"timestamp":"2026-02-25T10:00:04Z"}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.Equal(t, 4, len(msgs),
|
|
"expected user, assistant(toolCall), toolResult, assistant(toolCall)")
|
|
|
|
// The assistant turn that is only a toolCall must survive and
|
|
// carry its parsed call rather than being dropped as empty.
|
|
assistant := msgs[1]
|
|
assert.Equal(t, RoleAssistant, assistant.Role)
|
|
assert.True(t, assistant.HasToolUse, "toolCall block must set HasToolUse")
|
|
require.Equal(t, 1, len(assistant.ToolCalls), "expected 1 parsed tool call")
|
|
assert.Equal(t, "bash", assistant.ToolCalls[0].ToolName)
|
|
assert.Equal(t, "call_1", assistant.ToolCalls[0].ToolUseID)
|
|
assert.Contains(t, assistant.ToolCalls[0].InputJSON, "ls")
|
|
|
|
// The standalone toolResult message pairs by toolCallId and
|
|
// extracts the text from the toolResult content block.
|
|
result := msgs[2]
|
|
assert.Equal(t, RoleUser, result.Role)
|
|
require.Equal(t, 1, len(result.ToolResults), "expected 1 tool result")
|
|
assert.Equal(t, "call_1", result.ToolResults[0].ToolUseID)
|
|
assert.Equal(t, len("file1\nfile2"), result.ToolResults[0].ContentLength,
|
|
"toolResult content block text must be extracted")
|
|
// ContentRaw must be stored so pairToolResults can decode it into
|
|
// tool_calls.result_content for the UI, search, copy, and exports.
|
|
require.NotEmpty(t, result.ToolResults[0].ContentRaw,
|
|
"ContentRaw must be stored for downstream decoding")
|
|
assert.Equal(t, "file1\nfile2",
|
|
DecodeContent(result.ToolResults[0].ContentRaw),
|
|
"stored ContentRaw must decode to the tool result text")
|
|
|
|
// An arguments-only toolCall still yields InputJSON from "arguments".
|
|
argsOnly := msgs[3]
|
|
require.Equal(t, 1, len(argsOnly.ToolCalls), "expected 1 parsed tool call")
|
|
assert.Equal(t, "read", argsOnly.ToolCalls[0].ToolName)
|
|
assert.Contains(t, argsOnly.ToolCalls[0].InputJSON, "/etc/hosts")
|
|
}
|
|
|
|
func TestParseOpenClawSession_OrphanToolResult(t *testing.T) {
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"orphan-tr","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"read","input":{}}],"timestamp":"2026-02-25T10:00:02Z"}}`,
|
|
// toolResult with empty toolCallId — should be dropped
|
|
`{"type":"message","id":"m3","timestamp":"2026-02-25T10:00:03Z","message":{"role":"toolResult","toolCallId":"","toolName":"read","content":[{"type":"text","text":"orphan result"}],"isError":false,"timestamp":"2026-02-25T10:00:03Z"}}`,
|
|
`{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-02-25T10:00:04Z"}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
// 3 messages: user, assistant (tool_use), assistant (text).
|
|
// The orphan toolResult is skipped entirely.
|
|
require.Equal(t, 3, len(msgs), "expected 3 messages, got %d")
|
|
assert.Equal(t, 3, sess.MessageCount, "MessageCount = %d, want 3")
|
|
assert.Equal(t, 1, sess.UserMessageCount, "UserMessageCount = %d, want 1")
|
|
for _, m := range msgs {
|
|
assert.False(t, m.Role == RoleUser && m.Content == "", "blank user message leaked through")
|
|
}
|
|
}
|
|
|
|
func TestParseOpenClawSession_EmptyFile(t *testing.T) {
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"empty","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
)
|
|
|
|
sess, _, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, sess, "expected nil session for file with no messages")
|
|
}
|
|
|
|
func TestParseOpenClawSession_AssistantUsage(t *testing.T) {
|
|
// Synthetic fixture covering the OpenClaw assistant-turn usage
|
|
// shape: per-message provider/model and a usage block with
|
|
// short-name token counts plus a nested cost object.
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"usage-1","timestamp":"2026-04-30T12:00:00Z","cwd":"/home/user/proj"}`,
|
|
`{"type":"model_change","id":"mc1","timestamp":"2026-04-30T12:00:00Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}`,
|
|
`{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"do a thing"}],"timestamp":"2026-04-30T12:00:01Z"}}`,
|
|
`{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":91,"cacheRead":0,"cacheWrite":9612,"totalTokens":9706,"cost":{"input":0.000009,"output":0.001365,"cacheRead":0,"cacheWrite":0.036045,"total":0.037419}}}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, len(msgs), "expected 2 messages, got %d")
|
|
|
|
a := msgs[1]
|
|
require.Equal(t, RoleAssistant, a.Role, "expected assistant role, got %s")
|
|
assert.Equal(t, "claude-sonnet-4-6", a.Model, "Model = %q, want claude-sonnet-4-6")
|
|
assert.Equal(t, 91, a.OutputTokens, "OutputTokens = %d, want 91")
|
|
assert.True(t, a.HasOutputTokens, "HasOutputTokens = false, want true")
|
|
// ContextTokens = input + cacheRead + cacheWrite.
|
|
assert.Equal(t, 9615, a.ContextTokens, "ContextTokens = %d, want 9615")
|
|
assert.True(t, a.HasContextTokens, "HasContextTokens = false, want true")
|
|
// TokenUsage must be normalized to Anthropic-style keys so
|
|
// downstream usage aggregation (internal/db/usage.go) can
|
|
// read input_tokens/output_tokens/cache_*_input_tokens.
|
|
require.False(t, len(a.TokenUsage) == 0, "TokenUsage empty, want normalized JSON")
|
|
tu := string(a.TokenUsage)
|
|
for _, want := range []string{
|
|
`"input_tokens":3`,
|
|
`"output_tokens":91`,
|
|
`"cache_read_input_tokens":0`,
|
|
`"cache_creation_input_tokens":9612`,
|
|
} {
|
|
assert.Contains(t, tu, want)
|
|
}
|
|
|
|
// Session-level rollup must reflect the per-message totals.
|
|
assert.True(t, sess.HasTotalOutputTokens, "sess.HasTotalOutputTokens = false, want true")
|
|
assert.Equal(t, 91, sess.TotalOutputTokens, "TotalOutputTokens")
|
|
assert.True(t, sess.HasPeakContextTokens, "sess.HasPeakContextTokens = false, want true")
|
|
assert.Equal(t, 9615, sess.PeakContextTokens, "PeakContextTokens")
|
|
}
|
|
|
|
func TestParseOpenClawSession_AssistantUsageWithoutCost(t *testing.T) {
|
|
// Older sessions may carry a usage block without the nested
|
|
// cost object. Token extraction must still succeed and not
|
|
// crash on the missing field.
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"usage-2","timestamp":"2026-04-30T12:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}],"timestamp":"2026-04-30T12:00:01Z"}}`,
|
|
`{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"hi back"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"input":42,"output":17,"cacheRead":0,"cacheWrite":0,"totalTokens":59}}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, len(msgs), "expected 2 messages, got %d")
|
|
|
|
a := msgs[1]
|
|
assert.Equal(t, "claude-haiku-4-5", a.Model, "Model = %q, want claude-haiku-4-5")
|
|
assert.Equal(t, 17, a.OutputTokens, "OutputTokens = %d, want 17")
|
|
assert.Equal(t, 42, a.ContextTokens, "ContextTokens = %d, want 42")
|
|
assert.False(t, len(a.TokenUsage) == 0, "TokenUsage empty, want normalized JSON")
|
|
assert.Equal(t, 17, sess.TotalOutputTokens, "TotalOutputTokens")
|
|
}
|
|
|
|
func TestParseOpenClawSession_PartialUsage(t *testing.T) {
|
|
// Partial usage block: only output is present in the source.
|
|
// applyOpenClawAssistantUsage normalizes to a 4-key JSON, but
|
|
// HasContextTokens must still be false. TokenPresence() must
|
|
// trust the parser's explicit flags rather than inferring from
|
|
// the always-populated normalized keys.
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"partial","timestamp":"2026-04-30T12:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}],"timestamp":"2026-04-30T12:00:01Z"}}`,
|
|
`{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"output":17}}}`,
|
|
)
|
|
|
|
_, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, len(msgs), "expected 2 messages, got %d")
|
|
|
|
a := msgs[1]
|
|
assert.False(t, a.HasContextTokens, "HasContextTokens = true, want false")
|
|
assert.True(t, a.HasOutputTokens, "HasOutputTokens = false, want true")
|
|
|
|
hasCtx, hasOut := a.TokenPresence()
|
|
assert.False(t, hasCtx,
|
|
"TokenPresence ctx = true, want false (parser flags must take precedence over JSON keys)")
|
|
assert.True(t, hasOut, "TokenPresence out = false, want true")
|
|
}
|
|
|
|
func TestParseOpenClawSession_NoUsage(t *testing.T) {
|
|
// Assistant turn without any usage block: the parser is still
|
|
// authoritative — both presence flags must be false and stick.
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"nousage","timestamp":"2026-04-30T12:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}],"timestamp":"2026-04-30T12:00:01Z"}}`,
|
|
`{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z"}}`,
|
|
)
|
|
|
|
_, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, len(msgs), "expected 2 messages, got %d")
|
|
|
|
hasCtx, hasOut := msgs[1].TokenPresence()
|
|
assert.False(t, hasCtx, "TokenPresence ctx")
|
|
assert.False(t, hasOut, "TokenPresence out")
|
|
}
|
|
|
|
func TestParseOpenClawSession_Compaction(t *testing.T) {
|
|
path, _ := writeOpenClawTestFile(t, "main",
|
|
`{"type":"session","version":3,"id":"compact","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"compaction","id":"c1","timestamp":"2026-02-25T10:00:01Z","summary":"Previous work summary"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:02Z","message":{"role":"user","content":[{"type":"text","text":"Continue from here"}],"timestamp":"2026-02-25T10:00:02Z"}}`,
|
|
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"Continuing..."}],"timestamp":"2026-02-25T10:00:03Z"}}`,
|
|
)
|
|
|
|
sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test")
|
|
require.NoError(t, err)
|
|
require.False(t, sess == nil, "expected session, got nil")
|
|
// Compaction should be skipped, only messages remain.
|
|
assert.Equal(t, 2, len(msgs), "expected 2 messages (compaction skipped), got %d")
|
|
}
|
|
|
|
func TestParseOpenClawSession_AgentIDInSessionID(t *testing.T) {
|
|
// Verify different agent subdirectories produce distinct
|
|
// session IDs even when the raw session ID is the same.
|
|
pathA, _ := writeOpenClawTestFile(t, "alpha",
|
|
`{"type":"session","version":3,"id":"same-id","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
)
|
|
pathB, _ := writeOpenClawTestFile(t, "beta",
|
|
`{"type":"session","version":3,"id":"same-id","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`,
|
|
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`,
|
|
)
|
|
|
|
sessA, _, err := parseOpenClawSessionForTest(t, pathA, "", "test")
|
|
require.NoError(t, err)
|
|
sessB, _, err := parseOpenClawSessionForTest(t, pathB, "", "test")
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEqualf(t, sessB.ID, sessA.ID,
|
|
"expected different session IDs for different agents, both got %s", sessA.ID)
|
|
assert.Equal(t, "openclaw:alpha:same-id", sessA.ID, "expected openclaw:alpha:same-id, got %s")
|
|
assert.Equal(t, "openclaw:beta:same-id", sessB.ID, "expected openclaw:beta:same-id, got %s")
|
|
}
|
|
|
|
func TestIsOpenClawSessionFile(t *testing.T) {
|
|
accepted := []string{
|
|
"abc.jsonl",
|
|
"abc.jsonl.deleted.2026-02-19T08-59-24.951Z",
|
|
"abc.jsonl.reset.2026-02-17T09-39-39.691Z",
|
|
"abc.jsonl.full.bak",
|
|
}
|
|
rejected := []string{
|
|
"abc.jsonl.tmp",
|
|
"abc.jsonl.lock",
|
|
"abc.jsonl.partial",
|
|
"abc.json",
|
|
"sessions.json",
|
|
}
|
|
for _, name := range accepted {
|
|
assert.Truef(t, IsOpenClawSessionFile(name),
|
|
"expected %q to be accepted", name)
|
|
}
|
|
for _, name := range rejected {
|
|
assert.Falsef(t, IsOpenClawSessionFile(name),
|
|
"expected %q to be rejected", name)
|
|
}
|
|
}
|
|
|
|
func TestBestOpenClawEntry_CrossSuffix(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// reset is newer (March) than deleted (January), even though
|
|
// "deleted" > "reset" would be wrong lexicographically within
|
|
// the suffix family.
|
|
older := "abc.jsonl.deleted.2026-01-15T00-00-00.000Z"
|
|
newer := "abc.jsonl.reset.2026-03-01T00-00-00.000Z"
|
|
for _, name := range []string{older, newer} {
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, name), []byte("{}"), 0644,
|
|
))
|
|
}
|
|
|
|
files := discoverOpenClawSessionsForTest(t, root)
|
|
require.Equal(t, 1, len(files), "expected 1 (deduplicated), got %d")
|
|
assert.Equal(t, newer, filepath.Base(files[0].DisplayPath), "expected %q, got %q")
|
|
}
|
|
|
|
func TestDiscoverOpenClawSessions(t *testing.T) {
|
|
// Build a mock directory structure:
|
|
// <root>/main/sessions/sess1.jsonl
|
|
// <root>/main/sessions/sessions.json
|
|
// <root>/claude/sessions/sess2.jsonl
|
|
root := t.TempDir()
|
|
|
|
mainSessions := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(mainSessions, 0755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(mainSessions, "sess1.jsonl"), []byte("{}"), 0644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(mainSessions, "sessions.json"), []byte("{}"), 0644))
|
|
|
|
claudeSessions := filepath.Join(root, "claude", "sessions")
|
|
require.NoError(t, os.MkdirAll(claudeSessions, 0755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(claudeSessions, "sess2.jsonl"), []byte("{}"), 0644))
|
|
|
|
files := discoverOpenClawSessionsForTest(t, root)
|
|
require.Equal(t, 2, len(files), "expected 2 session files, got %d")
|
|
for _, f := range files {
|
|
assert.Equal(t, AgentOpenClaw, f.Provider, "expected agent openclaw, got %s")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverOpenClawSessions_DeduplicatesArchived(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// Active file and two archived files for the same session.
|
|
for _, name := range []string{
|
|
"abc.jsonl",
|
|
"abc.jsonl.deleted.2026-02-19T08-59-24.951Z",
|
|
"abc.jsonl.reset.2026-02-17T09-39-39.691Z",
|
|
} {
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, name),
|
|
[]byte("{}"), 0644,
|
|
))
|
|
}
|
|
|
|
files := discoverOpenClawSessionsForTest(t, root)
|
|
require.Equal(t, 1, len(files), "expected 1 file (deduplicated), got %d")
|
|
// Active file should win.
|
|
assert.Truef(t, strings.HasSuffix(files[0].DisplayPath, "abc.jsonl"),
|
|
"expected active .jsonl to win, got %s",
|
|
filepath.Base(files[0].DisplayPath))
|
|
}
|
|
|
|
func TestDiscoverOpenClawSessions_ArchiveOnlyPicksNewest(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// Two archived files, no active — newest filename wins.
|
|
for _, name := range []string{
|
|
"xyz.jsonl.deleted.2026-01-01T00-00-00.000Z",
|
|
"xyz.jsonl.deleted.2026-03-01T00-00-00.000Z",
|
|
} {
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, name),
|
|
[]byte("{}"), 0644,
|
|
))
|
|
}
|
|
|
|
files := discoverOpenClawSessionsForTest(t, root)
|
|
require.Equal(t, 1, len(files), "expected 1 file (deduplicated), got %d")
|
|
want := "xyz.jsonl.deleted.2026-03-01T00-00-00.000Z"
|
|
assert.Equal(t, want, filepath.Base(files[0].DisplayPath), "expected newest archive")
|
|
}
|
|
|
|
func TestDiscoverOpenClawSessions_DifferentSessionsNotDeduped(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// Two different session IDs — should not be deduplicated.
|
|
for _, name := range []string{
|
|
"aaa.jsonl",
|
|
"bbb.jsonl.deleted.2026-01-01T00-00-00.000Z",
|
|
} {
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, name),
|
|
[]byte("{}"), 0644,
|
|
))
|
|
}
|
|
|
|
files := discoverOpenClawSessionsForTest(t, root)
|
|
require.Len(t, files, 2, "expected 2 files (different sessions)")
|
|
}
|
|
|
|
func TestFindOpenClawSourceFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
target := filepath.Join(sessDir, "abc-123.jsonl")
|
|
require.NoError(t, os.WriteFile(target, []byte("{}"), 0644))
|
|
|
|
// Raw ID is now "agentId:sessionId".
|
|
found := findOpenClawSourceForTest(t, root, "main:abc-123")
|
|
assert.Equal(t, target, found, "expected %s, got %s")
|
|
|
|
// Non-existent session.
|
|
notFound := findOpenClawSourceForTest(t, root, "main:nonexistent")
|
|
assert.Equal(t, "", notFound, "expected empty string, got %s")
|
|
|
|
// Non-existent agent.
|
|
notFound2 := findOpenClawSourceForTest(t, root, "other:abc-123")
|
|
assert.Equal(t, "", notFound2, "expected empty string, got %s")
|
|
|
|
// Invalid format (no colon separator).
|
|
notFound3 := findOpenClawSourceForTest(t, root, "abc-123")
|
|
assert.Equal(t, "", notFound3, "expected empty string for bare ID, got %s")
|
|
}
|
|
|
|
func TestFindOpenClawSourceFile_ArchiveOnly(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// Only archived files exist — no active .jsonl.
|
|
archived := "def-456.jsonl.deleted.2026-02-19T08-59-24.951Z"
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, archived),
|
|
[]byte("{}"), 0644,
|
|
))
|
|
|
|
found := findOpenClawSourceForTest(t, root, "main:def-456")
|
|
want := filepath.Join(sessDir, archived)
|
|
assert.Equal(t, want, found, "expected %s, got %s")
|
|
}
|
|
|
|
func TestFindOpenClawSourceFile_PrefersActiveOverArchive(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// Both active and archived files exist.
|
|
active := filepath.Join(sessDir, "ghi-789.jsonl")
|
|
require.NoError(t, os.WriteFile(active, []byte("{}"), 0644))
|
|
archived := "ghi-789.jsonl.deleted.2026-02-19T00-00-00.000Z"
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, archived),
|
|
[]byte("{}"), 0644,
|
|
))
|
|
|
|
found := findOpenClawSourceForTest(t, root, "main:ghi-789")
|
|
assert.Equal(t, active, found, "expected active file %s, got %s")
|
|
}
|
|
|
|
func TestFindOpenClawSourceFile_ArchiveOnlyNewest(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "main", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0755))
|
|
|
|
// Two archived files — newest should be chosen.
|
|
old := "jkl.jsonl.deleted.2026-01-01T00-00-00.000Z"
|
|
newest := "jkl.jsonl.deleted.2026-03-01T00-00-00.000Z"
|
|
for _, name := range []string{old, newest} {
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sessDir, name),
|
|
[]byte("{}"), 0644,
|
|
))
|
|
}
|
|
|
|
found := findOpenClawSourceForTest(t, root, "main:jkl")
|
|
want := filepath.Join(sessDir, newest)
|
|
assert.Equal(t, want, found, "expected newest archive %s, got %s")
|
|
}
|