From ac8ee6525d015adfc8d44b418c32f5df5d52817f Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 31 Mar 2026 13:44:05 +0300 Subject: [PATCH] refactor(pkg/kit): replace fantasy type aliases with concrete LLM* structs Remove charm.land/fantasy from the public API surface of pkg/kit by replacing the four type aliases with concrete Kit-owned structs: - LLMMessage {Role LLMMessageRole, Content string} - LLMUsage {InputTokens, OutputTokens, TotalTokens, ...} - LLMResponse {Content, FinishReason, Usage} - LLMFilePart {Filename, Data []byte, MediaType} Add LLMMessageRole type with user/assistant/system/tool constants. Introduce pkg/kit/llm_convert.go as the single boundary layer where Kit types convert to/from fantasy types internally. All callers in pkg/kit, pkg/kit/compaction.go, pkg/kit/extensions_bridge.go, and internal/app/app.go cross through this layer. ContextPrepareHook.Messages and ContextPrepareResult.Messages change from []fantasy.Message to []LLMMessage. extensions_bridge.go drops its fantasy and strings imports entirely. internal/app/app_test.go switches &fantasy.Usage{} to &kit.LLMUsage{}. Add seven new tests in types_test.go covering concrete construction, role constants, JSON snake_case tags, and round-trip conversion. --- internal/app/app.go | 35 +++++++-- internal/app/app_test.go | 14 ++-- pkg/kit/README.md | 12 ++- pkg/kit/compaction.go | 15 ++-- pkg/kit/extensions_bridge.go | 31 +++----- pkg/kit/hooks.go | 4 +- pkg/kit/kit.go | 13 ++-- pkg/kit/llm_convert.go | 68 +++++++++++++++++ pkg/kit/types.go | 114 ++++++++++++++++++++++------- pkg/kit/types_test.go | 137 +++++++++++++++++++++++++++++++++++ skills/kit-sdk/SKILL.md | 18 +++-- 11 files changed, 376 insertions(+), 85 deletions(-) create mode 100644 pkg/kit/llm_convert.go diff --git a/internal/app/app.go b/internal/app/app.go index 30e014a9..de9d6524 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -637,7 +637,7 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M var result *kit.TurnResult var err error if len(files) > 0 { - result, err = a.opts.Kit.PromptResultWithFiles(ctx, prompt, files) + result, err = a.opts.Kit.PromptResultWithFiles(ctx, prompt, fantasyFilePartsToKit(files)) } else { result, err = a.opts.Kit.PromptResult(ctx, prompt) } @@ -646,7 +646,7 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M } // Sync in-memory store with the SDK's authoritative conversation. - a.store.Replace(result.Messages) + a.store.Replace(kitMessagesToFantasy(result.Messages)) // Update usage tracker. If per-step usage was already recorded from // StepUsageEvent callbacks, avoid double-counting totals. @@ -699,7 +699,7 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func( // Single item: use the original path for compatibility item := items[0] if len(item.Files) > 0 || hasFiles { - result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files) + result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, fantasyFilePartsToKit(item.Files)) } else { result, err = a.opts.Kit.PromptResult(ctx, item.Prompt) } @@ -716,7 +716,7 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func( // If files exist, fall back to processing just the first item with files for _, item := range items { if len(item.Files) > 0 { - result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files) + result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, fantasyFilePartsToKit(item.Files)) break } } @@ -730,7 +730,7 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func( } // Sync in-memory store with the SDK's authoritative conversation. - a.store.Replace(result.Messages) + a.store.Replace(kitMessagesToFantasy(result.Messages)) // Update usage tracker (using last item's prompt for fallback estimation). // If per-step usage was already recorded from StepUsageEvent callbacks, @@ -1083,3 +1083,28 @@ func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt strin a.opts.UsageTracker.SetContextTokens(int(result.FinalUsage.InputTokens)) } } + +// fantasyFilePartsToKit converts []fantasy.FilePart to []kit.LLMFilePart. +func fantasyFilePartsToKit(parts []fantasy.FilePart) []kit.LLMFilePart { + result := make([]kit.LLMFilePart, len(parts)) + for i, p := range parts { + result[i] = kit.LLMFilePart{ + Filename: p.Filename, + Data: p.Data, + MediaType: p.MediaType, + } + } + return result +} + +// kitMessagesToFantasy converts []kit.LLMMessage to []fantasy.Message. +func kitMessagesToFantasy(msgs []kit.LLMMessage) []fantasy.Message { + result := make([]fantasy.Message, len(msgs)) + for i, m := range msgs { + result[i] = fantasy.Message{ + Role: fantasy.MessageRole(m.Role), + Content: []fantasy.MessagePart{fantasy.TextPart{Text: m.Content}}, + } + } + return result +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0710e28a..d0096203 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -7,8 +7,6 @@ import ( "testing" "time" - "charm.land/fantasy" - kit "github.com/mark3labs/kit/pkg/kit" ) @@ -574,13 +572,13 @@ func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) { app.updateUsageFromTurnResult(&kit.TurnResult{ Response: "ok", - TotalUsage: &fantasy.Usage{ + TotalUsage: &kit.LLMUsage{ InputTokens: 999, OutputTokens: 111, CacheReadTokens: 7, CacheCreationTokens: 3, }, - FinalUsage: &fantasy.Usage{InputTokens: 456}, + FinalUsage: &kit.LLMUsage{InputTokens: 456}, }, "prompt", true) usage.mu.Lock() @@ -608,13 +606,13 @@ func TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero(t *testing.T) { // Simulate OpenAI-compatible behavior: all prompt tokens cached, InputTokens=0 app.updateUsageFromTurnResult(&kit.TurnResult{ Response: "ok", - TotalUsage: &fantasy.Usage{ + TotalUsage: &kit.LLMUsage{ InputTokens: 0, // All cached - subtracted from prompt OutputTokens: 150, // Actual generated tokens CacheReadTokens: 500, // Cache hit CacheCreationTokens: 0, }, - FinalUsage: &fantasy.Usage{InputTokens: 0, OutputTokens: 150}, + FinalUsage: &kit.LLMUsage{InputTokens: 0, OutputTokens: 150}, }, "prompt", false) usage.mu.Lock() @@ -642,11 +640,11 @@ func TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly(t *testing.T) { app.updateUsageFromTurnResult(&kit.TurnResult{ Response: "ok", - TotalUsage: &fantasy.Usage{ + TotalUsage: &kit.LLMUsage{ InputTokens: 1000, OutputTokens: 200, }, - FinalUsage: &fantasy.Usage{ + FinalUsage: &kit.LLMUsage{ InputTokens: 1000, // Full context including history OutputTokens: 200, }, diff --git a/pkg/kit/README.md b/pkg/kit/README.md index 6cea7794..7e678488 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -134,12 +134,16 @@ kit.Message, kit.MessageRole, kit.ContentPart kit.TextContent, kit.ReasoningContent, kit.ToolCall, kit.ToolResult, kit.Finish kit.RoleUser, kit.RoleAssistant, kit.RoleTool, kit.RoleSystem -// LLM types (re-exported from the underlying LLM library) -kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart +// LLM types — concrete Kit-owned structs, no external library dependency +kit.LLMMessage // {Role LLMMessageRole, Content string} +kit.LLMMessageRole // "user" | "assistant" | "system" | "tool" +kit.LLMUsage // {InputTokens, OutputTokens, TotalTokens, ...} +kit.LLMResponse // {Content, FinishReason, Usage} +kit.LLMFilePart // {Filename, Data []byte, MediaType} // Conversion helpers -msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages -msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message +msgs := kit.ConvertToLLMMessages(&msg) // SDK Message → []LLMMessage +msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message ``` ## API Reference diff --git a/pkg/kit/compaction.go b/pkg/kit/compaction.go index 44c39e47..e2ed9d86 100644 --- a/pkg/kit/compaction.go +++ b/pkg/kit/compaction.go @@ -140,7 +140,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust } // Extension provided a custom summary — use it directly. if hookResult.Summary != "" { - return m.applyCustomCompaction(hookResult.Summary, messages, opts) + return m.applyCustomCompaction(hookResult.Summary, fantasyToLLMMessages(messages), opts) } } } @@ -181,12 +181,13 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust // applyCustomCompaction handles compaction when an extension provides a // custom summary. It still determines the cut point and persists a // CompactionEntry. -func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message, opts *CompactionOptions) (*CompactionResult, error) { - originalTokens := compaction.EstimateMessageTokens(messages) +func (m *Kit) applyCustomCompaction(summary string, messages []LLMMessage, opts *CompactionOptions) (*CompactionResult, error) { + fantasyMessages := llmMessagesToFantasy(messages) + originalTokens := compaction.EstimateMessageTokens(fantasyMessages) - cutPoint := compaction.FindCutPoint(messages, opts.KeepRecentTokens) + cutPoint := compaction.FindCutPoint(fantasyMessages, opts.KeepRecentTokens) if cutPoint == 0 { - cutPoint = len(messages) - 1 + cutPoint = len(fantasyMessages) - 1 if cutPoint < 1 { return nil, nil } @@ -203,7 +204,7 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message, Role: "system", Content: []fantasy.MessagePart{fantasy.TextPart{Text: summary}}, }}) - recentTokens := compaction.EstimateMessageTokens(messages[cutPoint:]) + recentTokens := compaction.EstimateMessageTokens(fantasyMessages[cutPoint:]) compactedTokens := summaryTokens + recentTokens result := &CompactionResult{ @@ -249,3 +250,5 @@ func (m *Kit) persistAndEmitCompaction( }) return nil } + +// Conversion helpers are in llm_convert.go. diff --git a/pkg/kit/extensions_bridge.go b/pkg/kit/extensions_bridge.go index fd9328ef..7a53fe62 100644 --- a/pkg/kit/extensions_bridge.go +++ b/pkg/kit/extensions_bridge.go @@ -1,10 +1,8 @@ package kit import ( - "strings" "sync" - "charm.land/fantasy" "github.com/mark3labs/kit/internal/extensions" ) @@ -250,17 +248,10 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) { // Convert LLM message slice to extension ContextMessage slice. extMsgs := make([]extensions.ContextMessage, len(h.Messages)) for i, msg := range h.Messages { - // Extract text from content parts. - var text strings.Builder - for _, part := range msg.Content { - if tp, ok := part.(fantasy.TextPart); ok { - text.WriteString(tp.Text) - } - } extMsgs[i] = extensions.ContextMessage{ Index: i, Role: string(msg.Role), - Content: text.String(), + Content: msg.Content, } } @@ -271,27 +262,25 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) { } // Rebuild LLM message slice from extension result. - rebuilt := make([]fantasy.Message, 0, len(r.Messages)) + rebuilt := make([]LLMMessage, 0, len(r.Messages)) for _, cm := range r.Messages { if cm.Index >= 0 && cm.Index < len(h.Messages) { - // Reuse original message (preserves tool calls, reasoning, etc.) + // Reuse original message (preserves original role and content). rebuilt = append(rebuilt, h.Messages[cm.Index]) } else { // New message injected by extension. - role := fantasy.MessageRoleUser + role := LLMMessageRoleUser switch cm.Role { case "assistant": - role = fantasy.MessageRoleAssistant + role = LLMMessageRoleAssistant case "system": - role = fantasy.MessageRoleSystem + role = LLMMessageRoleSystem case "tool": - role = fantasy.MessageRoleTool + role = LLMMessageRoleTool } - rebuilt = append(rebuilt, fantasy.Message{ - Role: role, - Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: cm.Content}, - }, + rebuilt = append(rebuilt, LLMMessage{ + Role: role, + Content: cm.Content, }) } } diff --git a/pkg/kit/hooks.go b/pkg/kit/hooks.go index 4f7a395f..e122f269 100644 --- a/pkg/kit/hooks.go +++ b/pkg/kit/hooks.go @@ -83,14 +83,14 @@ type AfterTurnResult struct{} // messages are sent to the LLM. Hooks can filter, reorder, or inject messages. type ContextPrepareHook struct { // Messages is the current context as LLM message objects. - Messages []fantasy.Message + Messages []LLMMessage } // ContextPrepareResult can replace the context window. type ContextPrepareResult struct { // Messages replaces the entire context window. If nil, the original // messages are used. - Messages []fantasy.Message + Messages []LLMMessage } // BeforeCompactHook is the input for hooks that fire before compaction runs. diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 49d5a3fa..56013443 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -831,6 +831,7 @@ type TurnResult struct { // Messages is the full updated conversation after the turn, including // any tool call/result messages added during the agent loop. + // Each message carries role and plain-text content. Messages []LLMMessage } @@ -1195,8 +1196,8 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr messages := m.treeSession.GetLLMMessages() // Run ContextPrepare hooks — extensions can filter, reorder, or inject messages. - if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil { - messages = hookResult.Messages + if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: fantasyToLLMMessages(messages)}); hookResult != nil && hookResult.Messages != nil { + messages = llmToFantasyMessages(hookResult.Messages) } sentCount := len(messages) @@ -1258,12 +1259,12 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr Response: responseText, StopReason: stopReason, SessionID: m.GetSessionID(), - Messages: result.ConversationMessages, + Messages: fantasyToLLMMessages(result.ConversationMessages), } - totalUsage := result.TotalUsage + totalUsage := fantasyUsageToLLM(result.TotalUsage) turnResult.TotalUsage = &totalUsage if result.FinalResponse != nil { - finalUsage := result.FinalResponse.Usage + finalUsage := fantasyUsageToLLM(result.FinalResponse.Usage) turnResult.FinalUsage = &finalUsage } @@ -1434,7 +1435,7 @@ func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, er // clipboard images) that are included alongside the text in the user message. func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files []LLMFilePart) (*TurnResult, error) { return m.runTurn(ctx, message, message, []fantasy.Message{ - fantasy.NewUserMessage(message, files...), + fantasy.NewUserMessage(message, llmFilePartsToFantasy(files)...), }) } diff --git a/pkg/kit/llm_convert.go b/pkg/kit/llm_convert.go new file mode 100644 index 00000000..6015caff --- /dev/null +++ b/pkg/kit/llm_convert.go @@ -0,0 +1,68 @@ +package kit + +import ( + "strings" + + "charm.land/fantasy" +) + +// fantasyToLLMMessages converts a []fantasy.Message to []LLMMessage. +// Used at the boundary between internal agent/session code and the public SDK. +func fantasyToLLMMessages(msgs []fantasy.Message) []LLMMessage { + result := make([]LLMMessage, len(msgs)) + for i, fm := range msgs { + var b strings.Builder + for _, part := range fm.Content { + if tp, ok := part.(fantasy.TextPart); ok { + b.WriteString(tp.Text) + } + } + result[i] = LLMMessage{ + Role: LLMMessageRole(fm.Role), + Content: b.String(), + } + } + return result +} + +// llmToFantasyMessages converts a []LLMMessage to []fantasy.Message. +// Used when passing SDK types back into internal functions that still use fantasy. +func llmToFantasyMessages(msgs []LLMMessage) []fantasy.Message { + result := make([]fantasy.Message, len(msgs)) + for i, m := range msgs { + result[i] = fantasy.Message{ + Role: fantasy.MessageRole(m.Role), + Content: []fantasy.MessagePart{fantasy.TextPart{Text: m.Content}}, + } + } + return result +} + +// llmMessagesToFantasy is an alias for llmToFantasyMessages, for callers that +// use the older name. +var llmMessagesToFantasy = llmToFantasyMessages + +// fantasyUsageToLLM converts a fantasy.Usage to an LLMUsage. +func fantasyUsageToLLM(u fantasy.Usage) LLMUsage { + return LLMUsage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + ReasoningTokens: u.ReasoningTokens, + CacheCreationTokens: u.CacheCreationTokens, + CacheReadTokens: u.CacheReadTokens, + } +} + +// llmFilePartsToFantasy converts []LLMFilePart to []fantasy.FilePart. +func llmFilePartsToFantasy(parts []LLMFilePart) []fantasy.FilePart { + result := make([]fantasy.FilePart, len(parts)) + for i, p := range parts { + result[i] = fantasy.FilePart{ + Filename: p.Filename, + Data: p.Data, + MediaType: p.MediaType, + } + } + return result +} diff --git a/pkg/kit/types.go b/pkg/kit/types.go index 7afc3f9a..606847b3 100644 --- a/pkg/kit/types.go +++ b/pkg/kit/types.go @@ -2,6 +2,7 @@ package kit import ( "context" + "strings" "charm.land/fantasy" @@ -128,20 +129,66 @@ type SpinnerFunc = agent.SpinnerFunc // ==== LLM Types ==== -// LLMMessage is the underlying message type used by the LLM agent -// library. Re-exported so SDK users can work with LLM types without a -// direct import of the underlying LLM library. -type LLMMessage = fantasy.Message +// LLMMessageRole identifies the participant role in an LLM conversation. +type LLMMessageRole string -// LLMUsage contains token usage information from an LLM response. -type LLMUsage = fantasy.Usage +const ( + // LLMMessageRoleUser identifies a user message. + LLMMessageRoleUser LLMMessageRole = "user" + // LLMMessageRoleAssistant identifies an assistant message. + LLMMessageRoleAssistant LLMMessageRole = "assistant" + // LLMMessageRoleSystem identifies a system message. + LLMMessageRoleSystem LLMMessageRole = "system" + // LLMMessageRoleTool identifies a tool result message. + LLMMessageRoleTool LLMMessageRole = "tool" +) -// LLMResponse is the response type returned by the LLM agent library. -type LLMResponse = fantasy.Response +// LLMMessage represents a message in an LLM conversation. It carries the +// role and a plain-text representation of the message content. +type LLMMessage struct { + // Role is the participant role (user, assistant, system, tool). + Role LLMMessageRole `json:"role"` + // Content is the text content of the message. + Content string `json:"content"` +} -// LLMFilePart represents a file attachment (image, document, etc.) that can -// be included in a prompt via PromptResultWithFiles. -type LLMFilePart = fantasy.FilePart +// LLMUsage contains token usage information returned by the LLM provider. +type LLMUsage struct { + // InputTokens is the number of tokens in the prompt. + InputTokens int64 `json:"input_tokens"` + // OutputTokens is the number of tokens in the response. + OutputTokens int64 `json:"output_tokens"` + // TotalTokens is the total tokens used (input + output). + TotalTokens int64 `json:"total_tokens"` + // ReasoningTokens is the number of tokens used for chain-of-thought reasoning. + ReasoningTokens int64 `json:"reasoning_tokens"` + // CacheCreationTokens is the number of tokens written to the provider cache. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + // CacheReadTokens is the number of tokens read from the provider cache. + CacheReadTokens int64 `json:"cache_read_tokens"` +} + +// LLMResponse represents a response from the LLM provider. +type LLMResponse struct { + // Content is the text content of the response. + Content string `json:"content"` + // FinishReason explains why the LLM stopped generating + // (e.g. "stop", "length", "tool-calls", "error"). + FinishReason string `json:"finish_reason"` + // Usage contains the token usage for this response. + Usage LLMUsage `json:"usage"` +} + +// LLMFilePart represents a file attachment (image, document, audio, etc.) +// that can be included in a multimodal prompt via PromptResultWithFiles. +type LLMFilePart struct { + // Filename is the optional display name of the file. + Filename string `json:"filename"` + // Data is the raw file bytes. + Data []byte `json:"data"` + // MediaType is the MIME type of the file (e.g. "image/png", "application/pdf"). + MediaType string `json:"media_type"` +} // ==== Compaction Types (internal/compaction/) ==== @@ -177,24 +224,37 @@ func LoadSystemPrompt(pathOrContent string) (string, error) { // ==== Conversion Helpers ==== -// ConvertToLLMMessages converts an SDK message to the underlying LLM -// messages used by the agent for LLM interactions. -func ConvertToLLMMessages(msg *Message) []fantasy.Message { - return msg.ToLLMMessages() +// ConvertToLLMMessages converts an SDK message to a slice of LLMMessages. +// Each SDK message may expand to multiple LLM messages depending on its content. +func ConvertToLLMMessages(msg *Message) []LLMMessage { + raw := msg.ToLLMMessages() + result := make([]LLMMessage, 0, len(raw)) + for _, fm := range raw { + lm := LLMMessage{ + Role: LLMMessageRole(fm.Role), + Content: extractTextFromFantasyMessage(fm), + } + result = append(result, lm) + } + return result } -// ConvertFromLLMMessage converts an LLM message from the agent to an SDK -// message format for use in the SDK API. -func ConvertFromLLMMessage(msg fantasy.Message) Message { - return message.FromLLMMessage(msg) +// ConvertFromLLMMessage converts an LLMMessage to an SDK message. +func ConvertFromLLMMessage(msg LLMMessage) Message { + fm := fantasy.Message{ + Role: fantasy.MessageRole(msg.Role), + Content: []fantasy.MessagePart{fantasy.TextPart{Text: msg.Content}}, + } + return message.FromLLMMessage(fm) } -// Deprecated: Use ConvertToLLMMessages instead. -func ConvertToFantasyMessages(msg *Message) []fantasy.Message { - return ConvertToLLMMessages(msg) -} - -// Deprecated: Use ConvertFromLLMMessage instead. -func ConvertFromFantasyMessage(msg fantasy.Message) Message { - return ConvertFromLLMMessage(msg) +// extractTextFromFantasyMessage extracts plain text from a fantasy.Message. +func extractTextFromFantasyMessage(fm fantasy.Message) string { + var b strings.Builder + for _, part := range fm.Content { + if tp, ok := part.(fantasy.TextPart); ok { + b.WriteString(tp.Text) + } + } + return b.String() } diff --git a/pkg/kit/types_test.go b/pkg/kit/types_test.go index 3e8b6e0c..05071993 100644 --- a/pkg/kit/types_test.go +++ b/pkg/kit/types_test.go @@ -1,6 +1,7 @@ package kit_test import ( + "encoding/json" "testing" kit "github.com/mark3labs/kit/pkg/kit" @@ -59,3 +60,139 @@ func TestTypeExports(t *testing.T) { t.Errorf("round-trip Content() = %q, want %q", roundTrip.Content(), "test") } } + +// TestLLMMessageConcrete verifies LLMMessage is a concrete Kit-owned type +// with no dependency on charm.land/fantasy in its definition. +func TestLLMMessageConcrete(t *testing.T) { + msg := kit.LLMMessage{ + Role: kit.LLMMessageRoleUser, + Content: "hello world", + } + if msg.Role != "user" { + t.Errorf("LLMMessage.Role = %q, want %q", msg.Role, "user") + } + if msg.Content != "hello world" { + t.Errorf("LLMMessage.Content = %q, want %q", msg.Content, "hello world") + } + + // All role constants should match their string values. + if kit.LLMMessageRoleUser != "user" { + t.Errorf("LLMMessageRoleUser = %q, want %q", kit.LLMMessageRoleUser, "user") + } + if kit.LLMMessageRoleAssistant != "assistant" { + t.Errorf("LLMMessageRoleAssistant = %q, want %q", kit.LLMMessageRoleAssistant, "assistant") + } + if kit.LLMMessageRoleSystem != "system" { + t.Errorf("LLMMessageRoleSystem = %q, want %q", kit.LLMMessageRoleSystem, "system") + } + if kit.LLMMessageRoleTool != "tool" { + t.Errorf("LLMMessageRoleTool = %q, want %q", kit.LLMMessageRoleTool, "tool") + } +} + +// TestLLMUsageConcrete verifies LLMUsage is a concrete Kit-owned type. +func TestLLMUsageConcrete(t *testing.T) { + u := kit.LLMUsage{ + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + ReasoningTokens: 10, + CacheCreationTokens: 5, + CacheReadTokens: 20, + } + if u.InputTokens != 100 { + t.Errorf("LLMUsage.InputTokens = %d, want 100", u.InputTokens) + } + if u.TotalTokens != 150 { + t.Errorf("LLMUsage.TotalTokens = %d, want 150", u.TotalTokens) + } + + // Verify JSON marshaling uses snake_case. + data, err := json.Marshal(u) + if err != nil { + t.Fatalf("LLMUsage.MarshalJSON: %v", err) + } + if string(data) != `{"input_tokens":100,"output_tokens":50,"total_tokens":150,"reasoning_tokens":10,"cache_creation_tokens":5,"cache_read_tokens":20}` { + t.Errorf("LLMUsage JSON = %s", data) + } +} + +// TestLLMResponseConcrete verifies LLMResponse is a concrete Kit-owned type. +func TestLLMResponseConcrete(t *testing.T) { + r := kit.LLMResponse{ + Content: "here is my answer", + FinishReason: "stop", + Usage: kit.LLMUsage{ + InputTokens: 10, + OutputTokens: 5, + }, + } + if r.Content != "here is my answer" { + t.Errorf("LLMResponse.Content = %q, want %q", r.Content, "here is my answer") + } + if r.FinishReason != "stop" { + t.Errorf("LLMResponse.FinishReason = %q, want %q", r.FinishReason, "stop") + } +} + +// TestLLMFilePartConcrete verifies LLMFilePart is a concrete Kit-owned type. +func TestLLMFilePartConcrete(t *testing.T) { + fp := kit.LLMFilePart{ + Filename: "screenshot.png", + Data: []byte{0x89, 0x50, 0x4E, 0x47}, + MediaType: "image/png", + } + if fp.Filename != "screenshot.png" { + t.Errorf("LLMFilePart.Filename = %q, want %q", fp.Filename, "screenshot.png") + } + if fp.MediaType != "image/png" { + t.Errorf("LLMFilePart.MediaType = %q, want %q", fp.MediaType, "image/png") + } + if len(fp.Data) != 4 { + t.Errorf("LLMFilePart.Data len = %d, want 4", len(fp.Data)) + } +} + +// TestConvertToLLMMessages verifies round-trip conversion preserves content. +func TestConvertToLLMMessages(t *testing.T) { + msg := kit.Message{ + Role: kit.RoleUser, + Parts: []kit.ContentPart{kit.TextContent{Text: "what is 2+2?"}}, + } + llmMsgs := kit.ConvertToLLMMessages(&msg) + if len(llmMsgs) == 0 { + t.Fatal("ConvertToLLMMessages returned empty slice") + } + if llmMsgs[0].Role != kit.LLMMessageRoleUser { + t.Errorf("converted Role = %q, want %q", llmMsgs[0].Role, kit.LLMMessageRoleUser) + } + if llmMsgs[0].Content != "what is 2+2?" { + t.Errorf("converted Content = %q, want %q", llmMsgs[0].Content, "what is 2+2?") + } +} + +// TestConvertFromLLMMessage verifies LLMMessage → Message conversion. +func TestConvertFromLLMMessage(t *testing.T) { + llm := kit.LLMMessage{ + Role: kit.LLMMessageRoleAssistant, + Content: "the answer is 4", + } + msg := kit.ConvertFromLLMMessage(llm) + if msg.Role != kit.RoleAssistant { + t.Errorf("converted Role = %q, want %q", msg.Role, kit.RoleAssistant) + } + if msg.Content() != "the answer is 4" { + t.Errorf("converted Content() = %q, want %q", msg.Content(), "the answer is 4") + } +} + +// TestNoFantasyInLLMTypes verifies that none of the LLM* types require a +// fantasy import to construct — they are plain Go structs. +func TestNoFantasyInLLMTypes(t *testing.T) { + // If this file compiles without importing charm.land/fantasy, + // the types are properly encapsulated. This test just documents intent. + _ = kit.LLMMessage{Role: kit.LLMMessageRoleUser, Content: "hi"} + _ = kit.LLMUsage{InputTokens: 1} + _ = kit.LLMResponse{Content: "ok", FinishReason: "stop"} + _ = kit.LLMFilePart{Filename: "f.png", MediaType: "image/png"} +} diff --git a/skills/kit-sdk/SKILL.md b/skills/kit-sdk/SKILL.md index 4aeef80f..966b8463 100644 --- a/skills/kit-sdk/SKILL.md +++ b/skills/kit-sdk/SKILL.md @@ -120,15 +120,17 @@ result, err := host.PromptResult(ctx, "Analyze this file") // result.StopReason — "stop", "length", "tool-calls", "error", etc. // result.SessionID — session UUID // result.TotalUsage — aggregate tokens across all steps (*kit.LLMUsage) -// result.FinalUsage — tokens from last API call only +// LLMUsage{InputTokens, OutputTokens, TotalTokens, ...} +// result.FinalUsage — tokens from last API call only (*kit.LLMUsage) // result.Messages — full updated conversation ([]kit.LLMMessage) +// LLMMessage{Role kit.LLMMessageRole, Content string} ``` ### Multimodal with file attachments ```go files := []kit.LLMFilePart{{ - Name: "screenshot.png", + Filename: "screenshot.png", MediaType: "image/png", Data: imageBytes, }} @@ -640,15 +642,19 @@ kit.Config, kit.MCPServerConfig // Provider types kit.ProviderConfig, kit.ProviderResult, kit.ModelInfo, kit.ModelCost, kit.ModelLimit -// LLM types (re-exported from the underlying LLM library) -kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart +// LLM types — concrete Kit-owned structs (no external library dependency) +kit.LLMMessage // {Role LLMMessageRole, Content string} +kit.LLMMessageRole // "user" | "assistant" | "system" | "tool" +kit.LLMUsage // {InputTokens, OutputTokens, TotalTokens, ReasoningTokens, ...} +kit.LLMResponse // {Content, FinishReason, Usage} +kit.LLMFilePart // {Filename, Data []byte, MediaType} // Compaction types kit.CompactionResult, kit.CompactionOptions // Conversion helpers -msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages -msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message +msgs := kit.ConvertToLLMMessages(&msg) // SDK Message → []LLMMessage +msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message ``` ---