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
671 lines
20 KiB
Go
671 lines
20 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func runAmpParserTest(
|
|
t *testing.T, content string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
t.Helper()
|
|
path := createTestFile(t, "T-test.json", content)
|
|
return parseAmpTestSession(t, path, "local")
|
|
}
|
|
|
|
func parseAmpTestSession(
|
|
t *testing.T,
|
|
path string,
|
|
machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
t.Helper()
|
|
|
|
provider, ok := NewProvider(AgentAmp, ProviderConfig{
|
|
Roots: []string{filepath.Dir(path)},
|
|
Machine: machine,
|
|
})
|
|
require.True(t, ok)
|
|
|
|
outcome, err := provider.Parse(context.Background(), ParseRequest{
|
|
Source: SourceRef{
|
|
Provider: AgentAmp,
|
|
Key: path,
|
|
DisplayPath: path,
|
|
FingerprintKey: path,
|
|
Opaque: JSONLSource{
|
|
Root: filepath.Dir(path),
|
|
Path: path,
|
|
},
|
|
},
|
|
Machine: machine,
|
|
})
|
|
if err != nil || len(outcome.Results) == 0 {
|
|
return nil, nil, err
|
|
}
|
|
result := outcome.Results[0].Result
|
|
return &result.Session, result.Messages, nil
|
|
}
|
|
|
|
func TestAmpProviderParsesBasic(t *testing.T) {
|
|
threadID := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd"
|
|
content := `{
|
|
"v": 1,
|
|
"id": "` + threadID + `",
|
|
"created": 1704067200000,
|
|
"title": "Migrate database schema",
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "Migrate the DB schema."}]},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "Sure, I will help."}]}
|
|
],
|
|
"env": {
|
|
"initial": {
|
|
"trees": [{"displayName": "myproject", "uri": "file:///home/user/myproject"}]
|
|
}
|
|
},
|
|
"meta": {
|
|
"traces": [
|
|
{"name": "inference", "startTime": "2024-01-01T00:00:01Z", "endTime": "2024-01-01T00:00:02Z"},
|
|
{"name": "tools", "startTime": "2024-01-01T00:00:03Z", "endTime": "2024-01-01T00:00:05Z"}
|
|
]
|
|
}
|
|
}`
|
|
|
|
path := createTestFile(t, threadID+".json", content)
|
|
sess, msgs, err := parseAmpTestSession(t, path, "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
assertSessionMeta(t, sess,
|
|
"amp:"+threadID,
|
|
"myproject", AgentAmp,
|
|
)
|
|
|
|
// Title takes precedence as FirstMessage.
|
|
assert.Equal(t, "Migrate database schema", sess.FirstMessage)
|
|
assertMessageCount(t, sess.MessageCount, 2)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
|
|
// Start time from created (epoch ms).
|
|
wantStart := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
assertTimestamp(t, sess.StartedAt, wantStart)
|
|
|
|
// End time from meta.traces[last].endTime.
|
|
wantEnd := time.Date(2024, 1, 1, 0, 0, 5, 0, time.UTC)
|
|
assertTimestamp(t, sess.EndedAt, wantEnd)
|
|
|
|
require.Equal(t, 2, len(msgs))
|
|
assertMessage(t, msgs[0], RoleUser, "Migrate the DB schema.")
|
|
assertMessage(t, msgs[1], RoleAssistant, "Sure, I will help.")
|
|
assert.Equal(t, 0, msgs[0].Ordinal)
|
|
assert.Equal(t, 1, msgs[1].Ordinal)
|
|
}
|
|
|
|
func TestAmpProviderParsesToolUseAndThinking(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-tooluse",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "Read the file."}]},
|
|
{"role": "assistant", "content": [
|
|
{"type": "thinking", "thinking": "Let me plan this."},
|
|
{"type": "text", "text": "I will read it now."},
|
|
{"type": "tool_use", "complete": true, "id": "tu1", "name": "Read", "input": {"file_path": "main.go"}}
|
|
]},
|
|
{"role": "user", "content": [
|
|
{"type": "tool_result", "tool_use_id": "tu1", "content": "package main"}
|
|
]}
|
|
],
|
|
"env": {"initial": {"trees": [{"displayName": "myrepo"}]}},
|
|
"meta": {"traces": []}
|
|
}`
|
|
|
|
sess, msgs, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
assertMessageCount(t, sess.MessageCount, 3)
|
|
// tool_result-only user messages have no text content,
|
|
// so only the first user message (with text) is counted.
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
|
|
assert.False(t, msgs[0].HasThinking)
|
|
assert.False(t, msgs[0].HasToolUse)
|
|
|
|
assert.True(t, msgs[1].HasThinking)
|
|
assert.True(t, msgs[1].HasToolUse)
|
|
assert.Contains(t, msgs[1].Content, "[Thinking]")
|
|
assert.Contains(t, msgs[1].Content, "Let me plan this.")
|
|
assert.Contains(t, msgs[1].Content, "[Read: main.go]")
|
|
|
|
require.Equal(t, 1, len(msgs[1].ToolCalls))
|
|
assert.Equal(t, "Read", msgs[1].ToolCalls[0].ToolName)
|
|
assert.Equal(t, "Read", msgs[1].ToolCalls[0].Category)
|
|
assert.Equal(t, "tu1", msgs[1].ToolCalls[0].ToolUseID)
|
|
|
|
// tool_result message: content should be empty (no text blocks),
|
|
// but tool results are recorded.
|
|
assert.Equal(t, 1, len(msgs[2].ToolResults))
|
|
assert.Equal(t, "tu1", msgs[2].ToolResults[0].ToolUseID)
|
|
|
|
// EndTime absent (empty traces) → zero.
|
|
assertZeroTimestamp(t, sess.EndedAt, "EndedAt")
|
|
}
|
|
|
|
func TestSerializeAmpResult(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{name: "plain string", input: `"some text"`, want: "some text"},
|
|
{name: "empty string", input: `""`, want: ""},
|
|
{name: "number", input: `123`, want: "123"},
|
|
{name: "bool true", input: `true`, want: "true"},
|
|
{name: "bool false", input: `false`, want: "false"},
|
|
{name: "bash dict", input: `{"output":"hello\n","exitCode":0}`, want: "hello\n"},
|
|
{name: "bash dict output number", input: `{"output":0,"exitCode":0}`, want: "0"},
|
|
{name: "read dict", input: `{"absolutePath":"/a/b","content":"file data"}`, want: "file data"},
|
|
{name: "read dict content bool", input: `{"absolutePath":"/a/b","content":false}`, want: "false"},
|
|
{name: "edit dict", input: `{"diff":"--- a\n+++ b"}`, want: "--- a\n+++ b"},
|
|
{name: "success true", input: `{"success":true}`, want: "success"},
|
|
{name: "success false", input: `{"success":false}`, want: "failed"},
|
|
{name: "unknown dict fallback", input: `{"foo":"bar"}`, want: `{"foo":"bar"}`},
|
|
{name: "grep list strings", input: `["/a:1:x","/b:2:y"]`, want: "/a:1:x\n/b:2:y"},
|
|
{name: "mixed list string number", input: `["a",1]`, want: `["a",1]`},
|
|
{name: "mixed list string bool string", input: `["a",false,"b"]`, want: `["a",false,"b"]`},
|
|
{name: "screenshot list objects", input: `[{"type":"image","data":"..."}]`, want: "[binary content]"},
|
|
{name: "list of non-image objects", input: `[{"file":"foo.go","line":42},{"file":"bar.go","line":7}]`, want: `[{"file":"foo.go","line":42},{"file":"bar.go","line":7}]`},
|
|
{name: "list numbers", input: `[1,2,3]`, want: `[1,2,3]`},
|
|
{name: "empty array", input: `[]`, want: ""},
|
|
{name: "null", input: `null`, want: ""},
|
|
{name: "null output falls through to content", input: `{"output":null,"content":"fallback"}`, want: "fallback"},
|
|
{name: "null output falls through to diff", input: `{"output":null,"diff":"--- a"}`, want: "--- a"},
|
|
{name: "empty output falls through to content", input: `{"output":"","content":"fallback"}`, want: "fallback"},
|
|
{name: "empty output no fallback returns empty", input: `{"output":"","exitCode":0}`, want: ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := gjson.Parse(`{"result":` + tt.input + `}`).Get("result")
|
|
assert.Equal(t, tt.want, serializeAmpResult(result))
|
|
})
|
|
}
|
|
|
|
t.Run("not exists", func(t *testing.T) {
|
|
assert.Equal(t, "", serializeAmpResult(gjson.Result{}))
|
|
})
|
|
}
|
|
|
|
func TestExtractAmpToolResults(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantN int
|
|
wantID string
|
|
want string
|
|
}{
|
|
{
|
|
name: "canonical tool_use_id skipped",
|
|
input: `[{"type":"tool_result","tool_use_id":"tu1","content":"x"}]`,
|
|
wantN: 0,
|
|
},
|
|
{
|
|
name: "amp string result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":"text"}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "text",
|
|
},
|
|
{
|
|
name: "amp number result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":0}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "0",
|
|
},
|
|
{
|
|
name: "amp bool result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":false}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "false",
|
|
},
|
|
{
|
|
name: "amp dict result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":{"output":"ok","exitCode":0}}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "ok",
|
|
},
|
|
{
|
|
name: "amp dict output number",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":{"output":0,"exitCode":0}}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "0",
|
|
},
|
|
{
|
|
name: "amp list number result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":[1,2,3]}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "[1,2,3]",
|
|
},
|
|
{
|
|
name: "amp error result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"error","error":{"message":"fail"}}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "fail",
|
|
},
|
|
{
|
|
name: "amp unknown error",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"error"}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "[unknown error]",
|
|
},
|
|
{
|
|
name: "amp cancelled",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"cancelled"}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "[cancelled]",
|
|
},
|
|
{
|
|
name: "amp null result done",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":null}}]`,
|
|
wantN: 0,
|
|
},
|
|
{
|
|
name: "amp missing result and status",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"foo":"bar"}}]`,
|
|
wantN: 0,
|
|
},
|
|
{
|
|
// Empty array result: run.result existed so the block is preserved
|
|
// even though serialized text is empty, avoiding a "pending" tool call.
|
|
name: "amp empty array result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":[]}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "",
|
|
},
|
|
{
|
|
// Empty string result: same as above — result existed, preserve it.
|
|
name: "amp empty string result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":""}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "",
|
|
},
|
|
{
|
|
// Array of non-image objects should round-trip as raw JSON, not "[binary content]".
|
|
name: "amp list of objects result",
|
|
input: `[{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":[{"file":"foo.go","line":42}]}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: `[{"file":"foo.go","line":42}]`,
|
|
},
|
|
{
|
|
name: "empty tool_use_id falls through to toolUseID",
|
|
input: `[{"type":"tool_result","tool_use_id":"","toolUseID":"tu1","run":{"status":"done","result":"text"}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "text",
|
|
},
|
|
{
|
|
name: "null tool_use_id falls through to toolUseID",
|
|
input: `[{"type":"tool_result","tool_use_id":null,"toolUseID":"tu1","run":{"status":"done","result":"ok"}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "ok",
|
|
},
|
|
{
|
|
name: "non tool_result block",
|
|
input: `[{"type":"text","text":"hello"}]`,
|
|
wantN: 0,
|
|
},
|
|
{
|
|
name: "mixed canonical and amp",
|
|
input: `[{"type":"tool_result","tool_use_id":"tu0","content":"x"},{"type":"tool_result","toolUseID":"tu1","run":{"status":"done","result":"text"}}]`,
|
|
wantN: 1,
|
|
wantID: "tu1",
|
|
want: "text",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := extractAmpToolResults(gjson.Parse(tt.input))
|
|
assert.Equal(t, tt.wantN, len(got))
|
|
if tt.wantN == 0 {
|
|
return
|
|
}
|
|
|
|
assert.Equal(t, tt.wantID, got[0].ToolUseID)
|
|
assert.Equal(t, tt.want, DecodeContent(got[0].ContentRaw))
|
|
assert.Equal(t, len(tt.want), got[0].ContentLength)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAmpProviderParsesAmpToolResultSchema(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-amp-tool-result-schema",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role":"assistant","content":[
|
|
{"type":"text","text":"running librarian"},
|
|
{"type":"tool_use","id":"toolu1","name":"librarian","input":{"query":"x"}}
|
|
]},
|
|
{"role":"user","content":[
|
|
{"type":"tool_result","toolUseID":"toolu1","run":{"status":"done","result":"Here is a complete breakdown"}}
|
|
]}
|
|
]
|
|
}`
|
|
|
|
sess, msgs, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolResults, 1)
|
|
assert.Equal(t, "toolu1", msgs[1].ToolResults[0].ToolUseID)
|
|
assert.Equal(t, "Here is a complete breakdown", DecodeContent(msgs[1].ToolResults[0].ContentRaw))
|
|
}
|
|
|
|
func TestAmpProviderParsesAmpToolResultDict(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-amp-tool-result-dict",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role":"assistant","content":[
|
|
{"type":"text","text":"running bash"},
|
|
{"type":"tool_use","id":"toolu1","name":"Bash","input":{"command":"echo ok"}}
|
|
]},
|
|
{"role":"user","content":[
|
|
{"type":"tool_result","toolUseID":"toolu1","run":{"status":"done","result":{"output":"cmd output","exitCode":0}}}
|
|
]}
|
|
]
|
|
}`
|
|
|
|
_, msgs, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolResults, 1)
|
|
assert.Equal(t, "cmd output", DecodeContent(msgs[1].ToolResults[0].ContentRaw))
|
|
}
|
|
|
|
func TestAmpProviderParsesNoEnv(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-noenv",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
sess, msgs, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
// No env → project falls back to "amp".
|
|
assert.Equal(t, "amp", sess.Project)
|
|
require.Equal(t, 1, len(msgs))
|
|
}
|
|
|
|
func TestAmpProviderParsesNoTitle(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-notitle",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "Fix the bug in main.go please."}]},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "Done."}]}
|
|
]
|
|
}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
// No title → FirstMessage extracted from first user message.
|
|
assert.Equal(t, "Fix the bug in main.go please.", sess.FirstMessage)
|
|
}
|
|
|
|
func TestAmpProviderParsesNoMetaTraces(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-notraces",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
],
|
|
"meta": {}
|
|
}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
// No traces → EndedAt is zero.
|
|
assertZeroTimestamp(t, sess.EndedAt, "EndedAt")
|
|
}
|
|
|
|
func TestAmpProviderParsesLastTraceWithoutEndTime(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-trace-end-missing",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
],
|
|
"meta": {
|
|
"traces": [
|
|
{"name": "inference", "endTime": "2024-01-01T00:00:02Z"},
|
|
{"name": "tools"}
|
|
]
|
|
}
|
|
}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
// Earlier trace endTime is used when the last trace has none.
|
|
assert.Equal(t, "2024-01-01T00:00:02Z", sess.EndedAt.UTC().Format(time.RFC3339))
|
|
}
|
|
|
|
func TestAmpProviderParsesEmptyThread(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-empty",
|
|
"created": 1704067200000,
|
|
"messages": []
|
|
}`
|
|
|
|
sess, msgs, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
// Empty thread → nil, nil, nil (non-interactive).
|
|
assert.Nil(t, sess)
|
|
assert.Nil(t, msgs)
|
|
}
|
|
|
|
func TestAmpProviderParsesFirstMessageTruncation(t *testing.T) {
|
|
longText := strings.Repeat("a", 400)
|
|
content := `{"v":1,"id":"T-trunc","created":1704067200000,"messages":[` +
|
|
`{"role":"user","content":[{"type":"text","text":"` + longText + `"}]}]}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
// truncate clips at 300 chars + 3 ellipsis chars = 303.
|
|
assert.Equal(t, 303, len(sess.FirstMessage))
|
|
}
|
|
|
|
func TestAmpProviderParsesInvalidCreated(t *testing.T) {
|
|
t.Run("missing created", func(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-missing-created",
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assertZeroTimestamp(t, sess.StartedAt, "StartedAt")
|
|
})
|
|
|
|
t.Run("non numeric created", func(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-invalid-created",
|
|
"created": "nope",
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assertZeroTimestamp(t, sess.StartedAt, "StartedAt")
|
|
})
|
|
|
|
t.Run("non positive numeric created", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
created string
|
|
}{
|
|
{name: "zero", created: "0"},
|
|
{name: "negative", created: "-1"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-invalid-created-numeric",
|
|
"created": ` + tc.created + `,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assertZeroTimestamp(t, sess.StartedAt, "StartedAt")
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAmpProviderParsesErrors(t *testing.T) {
|
|
t.Run("missing file", func(t *testing.T) {
|
|
_, _, err := parseAmpTestSession(t, "/nonexistent/T-xxx.json", "local")
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid JSON", func(t *testing.T) {
|
|
_, _, err := runAmpParserTest(t, "{not valid json")
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("missing id falls back to filename", func(t *testing.T) {
|
|
content := `{"v":1,"created":1704067200000,"messages":[` +
|
|
`{"role":"user","content":[{"type":"text","text":"hello"}]}]}`
|
|
sess, _, err := runAmpParserTest(t, content)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
// Falls back to filename-derived ID (T-test from T-test.json).
|
|
assert.Equal(t, "amp:T-test", sess.ID)
|
|
})
|
|
|
|
t.Run("missing id and invalid filename", func(t *testing.T) {
|
|
content := `{"v":1,"created":1704067200000,"messages":[]}`
|
|
path := createTestFile(t, "bad-name.json", content)
|
|
_, _, err := parseAmpTestSession(t, path, "local")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "missing or invalid id")
|
|
})
|
|
}
|
|
|
|
func TestAmpProviderParsesMismatchedID(t *testing.T) {
|
|
t.Run("invalid JSON id", func(t *testing.T) {
|
|
content := `{
|
|
"v": 1,
|
|
"id": "bogus-not-a-thread-id",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
path := createTestFile(t, "T-fallback-uuid.json", content)
|
|
sess, _, err := parseAmpTestSession(t, path, "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.Equal(t, "amp:T-fallback-uuid", sess.ID)
|
|
})
|
|
|
|
t.Run("valid JSON id with invalid filename", func(t *testing.T) {
|
|
// Filename doesn't match T-<id>.json, so the parser
|
|
// falls back to the JSON id.
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-from-json",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
path := createTestFile(t, "bad-name.json", content)
|
|
sess, _, err := parseAmpTestSession(t, path, "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.Equal(t, "amp:T-from-json", sess.ID)
|
|
})
|
|
|
|
t.Run("valid JSON id differs from filename", func(t *testing.T) {
|
|
// Both the JSON id and filename are valid T-<id>
|
|
// patterns, but they disagree. Filename must win so
|
|
// FindAmpSourceFile can locate the file by ID.
|
|
content := `{
|
|
"v": 1,
|
|
"id": "T-from-json",
|
|
"created": 1704067200000,
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
|
]
|
|
}`
|
|
|
|
path := createTestFile(t, "T-from-file.json", content)
|
|
sess, _, err := parseAmpTestSession(t, path, "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.Equal(t, "amp:T-from-file", sess.ID)
|
|
})
|
|
}
|
|
|
|
func TestAmpThreadID(t *testing.T) {
|
|
data := []byte(`{"id":"T-abc123","v":1}`)
|
|
assert.Equal(t, "T-abc123", AmpThreadID(data))
|
|
|
|
assert.Equal(t, "", AmpThreadID([]byte(`{"v":1}`)))
|
|
}
|