Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1006 lines
28 KiB
Go

package parser
import (
"database/sql"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const forgeSchema = `
CREATE TABLE conversations (
conversation_id TEXT PRIMARY KEY NOT NULL,
title TEXT,
workspace_id BIGINT NOT NULL,
context TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
metrics TEXT
);
CREATE INDEX idx_conversations_workspace_created ON conversations(workspace_id, created_at DESC);
CREATE INDEX idx_conversations_active_workspace_updated
ON conversations(workspace_id, updated_at DESC)
WHERE context IS NOT NULL;
`
type ForgeSeeder struct {
db *sql.DB
t *testing.T
}
func (s *ForgeSeeder) AddConversation(
conversationID, title string,
workspaceID int64,
context, createdAt, updatedAt, metrics string,
) {
s.t.Helper()
_, err := s.db.Exec(
`INSERT INTO conversations
(conversation_id, title, workspace_id, context, created_at, updated_at, metrics)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
conversationID, title, workspaceID, context, createdAt, updatedAt, metrics,
)
require.NoError(s.t, err, "add conversation")
}
func newForgeTestDB(t *testing.T) (string, *ForgeSeeder, *sql.DB) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), ".forge.db")
db, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err, "open test db")
_, err = db.Exec(forgeSchema)
require.NoError(t, err, "create schema")
seeder := &ForgeSeeder{db: db, t: t}
return dbPath, seeder, db
}
func seedForgeConversation(t *testing.T, seeder *ForgeSeeder) {
t.Helper()
context := `{
"conversation_id": "conv-001",
"messages": [
{
"message": {
"text": {
"role": "System",
"content": "<system_information>\n<current_working_directory>/home/mj/dev/projects/agentsview</current_working_directory>\n</system_information>",
"model": "gpt-5.4",
"timestamp": "2026-05-02T09:58:15.741021507Z"
}
},
"usage": {
"prompt_tokens": {"actual": 0},
"completion_tokens": {"actual": 0},
"cached_tokens": {"actual": 0}
}
},
{
"message": {
"text": {
"role": "User",
"content": "Please add Forge support.",
"raw_content": {"Text": "Please add Forge support."},
"model": "gpt-5.4",
"timestamp": "2026-05-02T09:58:16.000000000Z"
}
},
"usage": {
"prompt_tokens": {"actual": 100},
"completion_tokens": {"actual": 5},
"cached_tokens": {"actual": 20}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "",
"tool_calls": [
{
"name": "read",
"call_id": "call_read_1",
"arguments": {"file_path": "/tmp/example.go", "show_line_numbers": true}
}
],
"model": "gpt-5.4",
"reasoning_details": [
{"text": "Inspecting the code first."}
],
"timestamp": "2026-05-02T09:58:17.000000000Z"
}
},
"usage": {
"prompt_tokens": {"actual": 120},
"completion_tokens": {"actual": 10},
"cached_tokens": {"actual": 30}
}
},
{
"message": {
"tool": {
"name": "read",
"call_id": "call_read_1",
"output": {
"is_error": false,
"values": [
{"text": "<file path=\"/tmp/example.go\">package main</file>"}
]
}
}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "Added Forge support.",
"raw_content": {"Text": "Added Forge support."},
"model": "gpt-5.4",
"timestamp": "2026-05-02T09:58:18.000000000Z"
}
},
"usage": {
"prompt_tokens": {"actual": 140},
"completion_tokens": {"actual": 40},
"cached_tokens": {"actual": 35}
}
}
]
}`
metrics := `{"input_tokens":360,"output_tokens":55,"cached_input_tokens":85}`
seeder.AddConversation(
"conv-001",
"Add Forge Support",
123,
context,
"2026-05-02 09:58:15.741021507",
"2026-05-02 10:00:16.848497543",
metrics,
)
}
func TestParseForgeDB_StandardConversation(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
seedForgeConversation(t, seeder)
sessions, err := parseForgeAll(dbPath, "testmachine")
require.NoError(t, err, "ParseForgeDB")
assertEq(t, "sessions len", len(sessions), 1)
s := sessions[0]
assertEq(t, "ID", s.Session.ID, "forge:conv-001")
assertEq(t, "Agent", s.Session.Agent, AgentForge)
assertEq(t, "Machine", s.Session.Machine, "testmachine")
assertEq(t, "Project", s.Session.Project, "agentsview")
assertEq(t, "SessionName", s.Session.SessionName, "Add Forge Support")
assertEq(t, "UserMessageCount", s.Session.UserMessageCount, 1)
assertEq(t, "FirstMessage", s.Session.FirstMessage, "Please add Forge support.")
assertEq(t, "Cwd", s.Session.Cwd, "/home/mj/dev/projects/agentsview")
assertEq(t, "File.Path", s.Session.File.Path, dbPath+"#conv-001")
assertEq(t, "HasTotalOutputTokens", s.Session.HasTotalOutputTokens, true)
assertEq(t, "TotalOutputTokens", s.Session.TotalOutputTokens, 55)
assertEq(t, "HasPeakContextTokens", s.Session.HasPeakContextTokens, true)
assertEq(t, "PeakContextTokens", s.Session.PeakContextTokens, 445)
assertEq(t, "messages len", len(s.Messages), 4)
assertEq(t, "tool result role", s.Messages[2].Role, RoleUser)
assertEq(t, "assistant tool call count", len(s.Messages[1].ToolCalls), 1)
assertEq(t, "assistant tool name", s.Messages[1].ToolCalls[0].ToolName, "read")
assertEq(t, "assistant tool category", s.Messages[1].ToolCalls[0].Category, "Read")
assertEq(t, "tool result count", len(s.Messages[2].ToolResults), 1)
assertEq(t, "tool result id", s.Messages[2].ToolResults[0].ToolUseID, "call_read_1")
assertEq(t, "final assistant content", s.Messages[3].Content, "Added Forge support.")
}
func TestParseForgeSession_SingleConversation(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
seedForgeConversation(t, seeder)
sess, msgs, err := parseForgeSession(dbPath, "conv-001", "testmachine")
require.NoError(t, err, "parseForgeSession")
require.NotNil(t, sess, "expected non-nil session")
assertEq(t, "ID", sess.ID, "forge:conv-001")
assertEq(t, "Agent", sess.Agent, AgentForge)
assertEq(t, "msgs len", len(msgs), 4)
assertEq(t, "msgs[0].Role", msgs[0].Role, RoleUser)
assertEq(t, "msgs[0].Content", msgs[0].Content, "Please add Forge support.")
assertEq(t, "msgs[1].Role", msgs[1].Role, RoleAssistant)
assertEq(t, "msgs[1].HasThinking", msgs[1].HasThinking, true)
assertEq(t, "msgs[1].HasToolUse", msgs[1].HasToolUse, true)
}
func TestListForgeSessionMeta(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
seedForgeConversation(t, seeder)
metas, err := ListForgeSessionMeta(dbPath)
require.NoError(t, err, "ListForgeSessionMeta")
assertEq(t, "metas len", len(metas), 1)
assertEq(t, "SessionID", metas[0].SessionID, "conv-001")
assertEq(t, "VirtualPath", metas[0].VirtualPath, dbPath+"#conv-001")
assert.NotZero(t, metas[0].FileMtime, "expected non-zero FileMtime")
}
func TestCollectForgeToolCalls_TaskSubagentIDPrefixed(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Run a subtask.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "",
"tool_calls": [
{
"name": "task",
"call_id": "call_task_1",
"arguments": {"session_id": "child-conv-001", "prompt": "do the thing"}
}
],
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"parent-conv", "Parent", 1, context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01", "",
)
sess, msgs, err := parseForgeSession(dbPath, "parent-conv", "m")
require.NoError(t, err, "parseForgeSession")
require.NotNil(t, sess, "expected non-nil session")
require.NotEmpty(t, msgs, "expected messages")
var taskCall *ParsedToolCall
for i := range msgs {
for j := range msgs[i].ToolCalls {
if msgs[i].ToolCalls[j].ToolName == "task" {
taskCall = &msgs[i].ToolCalls[j]
}
}
}
require.NotNil(t, taskCall, "expected task tool call")
assertEq(t, "SubagentSessionID", taskCall.SubagentSessionID, "forge:child-conv-001")
}
func TestForgeDBPath(t *testing.T) {
dir := t.TempDir()
assertEq(t, "not found", forgeDBPath(dir), "")
dbPath, _, db := newForgeTestDB(t)
defer db.Close()
assertEq(t, "found", forgeDBPath(filepath.Dir(dbPath)), dbPath)
}
// ---------------------------------------------------------------------------
// Priority 2 — Token/metrics fallback tests
// ---------------------------------------------------------------------------
func TestForgeTokenFallbacks(t *testing.T) {
// Case 1: no metrics column but per-message usage populated.
// accumulateMessageTokenUsage should aggregate from messages.
t.Run("no_metrics_per_message_usage", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Hello there.",
"timestamp": "2026-05-02T10:00:00Z"
}
},
"usage": {
"prompt_tokens": {"actual": 50},
"completion_tokens": {"actual": 10},
"cached_tokens": {"actual": 20}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "Hi back.",
"timestamp": "2026-05-02T10:00:01Z"
}
},
"usage": {
"prompt_tokens": {"actual": 80},
"completion_tokens": {"actual": 15},
"cached_tokens": {"actual": 30}
}
}
]
}`
seeder.AddConversation(
"token-fallback-1", "No Metrics", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"", // empty metrics → accumulateMessageTokenUsage fallback
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
s := sessions[0].Session
assertEq(t, "HasTotalOutputTokens", s.HasTotalOutputTokens, true)
assertEq(t, "TotalOutputTokens", s.TotalOutputTokens, 25) // 10+15
assertEq(t, "HasPeakContextTokens", s.HasPeakContextTokens, true)
// Peak is max(50+20=70, 80+30=110) = 110
assert.Contains(t, []int{110, 70}, s.PeakContextTokens,
"PeakContextTokens = %d, want 110", s.PeakContextTokens)
})
// Case 2: metrics has only output_tokens (no input, no cached).
t.Run("metrics_output_only", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Run something.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "Done.",
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"token-fallback-2", "Output Only Metrics", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
`{"output_tokens": 42}`,
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
s := sessions[0].Session
assertEq(t, "HasTotalOutputTokens", s.HasTotalOutputTokens, true)
assertEq(t, "TotalOutputTokens", s.TotalOutputTokens, 42)
assertEq(t, "HasPeakContextTokens", s.HasPeakContextTokens, false)
})
// Case 3: per-message usage with no cached_tokens key.
// ContextTokens should equal just prompt_tokens.actual.
t.Run("per_message_no_cached_tokens", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Tell me something.",
"timestamp": "2026-05-02T10:00:00Z"
}
},
"usage": {
"prompt_tokens": {"actual": 60},
"completion_tokens": {"actual": 8}
}
}
]
}`
seeder.AddConversation(
"token-fallback-3", "No Cached Tokens", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
msgs := sessions[0].Messages
require.NotEmpty(t, msgs, "want at least 1 message")
assertEq(t, "HasContextTokens", msgs[0].HasContextTokens, true)
assertEq(t, "ContextTokens", msgs[0].ContextTokens, 60) // only prompt, no cached
})
// Case 4: per-message usage entirely absent.
t.Run("per_message_no_usage", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Empty usage.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
}
]
}`
seeder.AddConversation(
"token-fallback-4", "No Usage At All", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
msgs := sessions[0].Messages
require.NotEmpty(t, msgs, "want at least 1 message")
m := msgs[0]
assertEq(t, "HasContextTokens", m.HasContextTokens, false)
assertEq(t, "HasOutputTokens", m.HasOutputTokens, false)
// tokenPresenceKnown is unexported; verify by checking TokenPresence()
hasCtx, hasOut := m.TokenPresence()
assertEq(t, "TokenPresence hasCtx", hasCtx, false)
assertEq(t, "TokenPresence hasOut", hasOut, false)
})
}
// ---------------------------------------------------------------------------
// Priority 7 — Degenerate conversation tests
// ---------------------------------------------------------------------------
func TestForgeDegenerate(t *testing.T) {
// Sub-case 1: assistant message with empty content, no reasoning, no tool_calls → skipped.
t.Run("empty_assistant_no_tools", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Hello.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "",
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"degen-1", "Empty Assistant", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
// Only the user message; empty assistant was skipped.
assertEq(t, "messages len", len(sessions[0].Messages), 1)
assertEq(t, "role", sessions[0].Messages[0].Role, RoleUser)
})
// Sub-case 2: tool message with empty call_id → skipped.
t.Run("tool_message_empty_call_id", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Run a tool.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"tool": {
"name": "bash",
"call_id": "",
"output": {"values": [{"text": "result"}]}
}
}
}
]
}`
seeder.AddConversation(
"degen-2", "Empty Call ID", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
// Only the user message; tool result with empty call_id was skipped.
assertEq(t, "messages len", len(sessions[0].Messages), 1)
})
// Sub-case 3: user message with empty content but populated raw_content.Text.
t.Run("user_raw_content_fallback", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "",
"raw_content": {"Text": "raw text fallback"},
"timestamp": "2026-05-02T10:00:00Z"
}
}
}
]
}`
seeder.AddConversation(
"degen-3", "Raw Content Fallback", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
msgs := sessions[0].Messages
assertEq(t, "messages len", len(msgs), 1)
assertEq(t, "content", msgs[0].Content, "raw text fallback")
})
}
// ---------------------------------------------------------------------------
// Priority 8 — cwd extraction edge cases
// ---------------------------------------------------------------------------
func TestForgeCwdEdgeCases(t *testing.T) {
// Case 1: no system message at all → Cwd and Project empty.
t.Run("no_system_message", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "No system message.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
}
]
}`
seeder.AddConversation(
"cwd-1", "No System", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
s := sessions[0].Session
assertEq(t, "Cwd", s.Cwd, "")
assertEq(t, "Project", s.Project, ExtractProjectFromCwd(""))
})
// Case 2: system message present but no cwd tag → same result.
t.Run("system_no_cwd_tag", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "System",
"content": "<system_information>no cwd here</system_information>",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "User",
"content": "Hi.",
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"cwd-2", "System No Tag", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
assertEq(t, "Cwd", sessions[0].Session.Cwd, "")
})
// Case 3: cwd tag present but empty content → Cwd empty.
t.Run("cwd_tag_empty_content", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "System",
"content": "<current_working_directory></current_working_directory>",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "User",
"content": "Hi again.",
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"cwd-3", "Empty CWD Tag", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
assertEq(t, "Cwd", sessions[0].Session.Cwd, "")
})
// Case 4: cwd in a non-system message → still extracted.
t.Run("cwd_in_non_system_message", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Working from <current_working_directory>/home/mj/dev/projects/myapp</current_working_directory> today.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
}
]
}`
seeder.AddConversation(
"cwd-4", "CWD In User Message", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
s := sessions[0].Session
assertEq(t, "Cwd", s.Cwd, "/home/mj/dev/projects/myapp")
assertEq(t, "Project", s.Project, "myapp")
})
}
// ---------------------------------------------------------------------------
// Lower priority — parseForgeTimestamp table-driven
// ---------------------------------------------------------------------------
func TestParseForgeTimestamp(t *testing.T) {
cases := []struct {
input string
empty bool
}{
{input: "2026-05-02T09:58:15.741021507Z", empty: false},
{input: "2026-05-02T09:58:15Z", empty: false},
{input: "2026-05-02 09:58:15.741021507", empty: false},
{input: "2026-05-02 09:58:15.741021", empty: false},
{input: "2026-05-02 09:58:15", empty: false},
{input: "", empty: true},
{input: "not-a-date", empty: true},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := parseForgeTimestamp(tc.input)
if tc.empty {
assert.True(t, got.IsZero(),
"parseForgeTimestamp(%q) = %v, want zero", tc.input, got)
} else {
assert.False(t, got.IsZero(),
"parseForgeTimestamp(%q) returned zero time", tc.input)
}
})
}
}
// ---------------------------------------------------------------------------
// Lower priority — endedAt fallback to startedAt
// ---------------------------------------------------------------------------
func TestForgeEndedAtFallback(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "No updated_at.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
}
]
}`
// Set updated_at to empty so loadForgeConversations COALESCE returns created_at,
// but force a NULL by inserting directly.
seeder.AddConversation(
"ended-fallback", "Ended At Fallback", 1,
context,
"2026-05-02 10:00:00", "",
"",
)
// Override to NULL updated_at.
_, err := db.Exec("UPDATE conversations SET updated_at = NULL WHERE conversation_id = 'ended-fallback'")
require.NoError(t, err, "update")
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
s := sessions[0].Session
assert.False(t, s.EndedAt.IsZero(), "EndedAt is zero, want fallback to StartedAt")
assert.True(t, s.StartedAt.Equal(s.EndedAt),
"EndedAt = %v, want StartedAt = %v", s.EndedAt, s.StartedAt)
}
// ---------------------------------------------------------------------------
// Lower priority — forgeToolOutputText fallback ladder
// ---------------------------------------------------------------------------
func TestForgeToolOutputText(t *testing.T) {
// values[].text covered in standard test; test top-level text fallback.
t.Run("top_level_text", func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Call a tool.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"tool": {
"name": "bash",
"call_id": "call_top_1",
"output": {
"text": "top-level text output"
}
}
}
}
]
}`
seeder.AddConversation(
"tool-output-1", "Top Level Text", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
msgs := sessions[0].Messages
require.GreaterOrEqual(t, len(msgs), 2, "want at least 2 messages")
// Second message is the tool result (role=user with ToolResults)
require.NotEmpty(t, msgs[1].ToolResults, "expected tool result")
})
}
// ---------------------------------------------------------------------------
// Lower priority — skill tool populates SkillName from arguments.name/.skill
// ---------------------------------------------------------------------------
func TestForgeSkillToolName(t *testing.T) {
cases := []struct {
name string
args string
wantSkill string
}{
{
name: "arguments.name",
args: `{"name": "my-skill"}`,
wantSkill: "my-skill",
},
{
name: "arguments.skill_fallback",
args: `{"skill": "fallback-skill"}`,
wantSkill: "fallback-skill",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Run skill.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "",
"tool_calls": [
{
"name": "skill",
"call_id": "call_skill_1",
"arguments": ` + tc.args + `
}
],
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"skill-test-"+tc.name, "Skill Test", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
var skillCall *ParsedToolCall
for i := range sessions[0].Messages {
for j := range sessions[0].Messages[i].ToolCalls {
if sessions[0].Messages[i].ToolCalls[j].ToolName == "skill" {
skillCall = &sessions[0].Messages[i].ToolCalls[j]
}
}
}
require.NotNil(t, skillCall, "expected skill tool call")
assertEq(t, "SkillName", skillCall.SkillName, tc.wantSkill)
})
}
}
// ---------------------------------------------------------------------------
// Lower priority — reasoning details with no text fields → HasThinking false
// ---------------------------------------------------------------------------
func TestForgeReasoningNoText(t *testing.T) {
dbPath, seeder, db := newForgeTestDB(t)
defer db.Close()
context := `{
"messages": [
{
"message": {
"text": {
"role": "User",
"content": "Think but say nothing.",
"timestamp": "2026-05-02T10:00:00Z"
}
}
},
{
"message": {
"text": {
"role": "Assistant",
"content": "Response.",
"reasoning_details": [
{"type": "thinking"},
{"type": "thinking"}
],
"timestamp": "2026-05-02T10:00:01Z"
}
}
}
]
}`
seeder.AddConversation(
"reasoning-no-text", "No Reasoning Text", 1,
context,
"2026-05-02 10:00:00", "2026-05-02 10:00:01",
"",
)
sessions, err := parseForgeAll(dbPath, "m")
require.NoError(t, err, "ParseForgeDB")
require.Len(t, sessions, 1)
msgs := sessions[0].Messages
// User + assistant
require.GreaterOrEqual(t, len(msgs), 2, "want at least 2 messages")
asst := msgs[1]
assertEq(t, "HasThinking", asst.HasThinking, false)
}