mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
refactor(pkg/kit): use fantasy type aliases for LLM types with clean SDK names
Replace concrete LLMMessage/LLMUsage/LLMResponse/LLMFilePart structs with type aliases to charm.land/fantasy types, exposing them under clean LLM-prefixed names. This gives SDK consumers full access to rich message parts (tool calls, reasoning, tool results) without importing fantasy directly. Key changes: - LLM types are now aliases: LLMMessage=fantasy.Message, etc. - Added aliases for all part types: LLMTextPart, LLMToolCallPart, etc. - Re-exported constructors: NewLLMUserMessage, NewLLMSystemMessage - Removed lossy conversion helpers (llm_convert.go, fantasyMsgsToKit) - Updated all internal packages to use aliases consistently - Added ACP smoke test script and prompt template - Fixed lint issues: unused vars, modernize min() usage
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Run ACP smoke test against opencode/kimi-k2.5 to verify JSON-RPC stdio works
|
||||
---
|
||||
|
||||
Run the ACP smoke test to verify the Kit ACP server works correctly over JSON-RPC stdio with streaming responses.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Build the kit binary:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
```
|
||||
|
||||
2. Run the smoke test Python script against opencode/kimi-k2.5:
|
||||
```bash
|
||||
python3 scripts/acp_smoke_test.py
|
||||
```
|
||||
|
||||
3. Verify the output shows:
|
||||
- `session/new` returns a valid `sessionId`
|
||||
- `session/prompt` streams `agent_thought_chunk` notifications (reasoning)
|
||||
- `session/prompt` streams `agent_message_chunk` notifications (response)
|
||||
- Final result has `stopReason: "end_turn"`
|
||||
- `✓ SMOKE TEST PASSED` at the end
|
||||
|
||||
4. If the test fails, check:
|
||||
- `output/kit` binary exists and is executable
|
||||
- `OPENCODE_API_KEY` or `OPENCODE_ZEN_API_KEY` environment variable is set
|
||||
- `scripts/acp_smoke_test.py` exists
|
||||
- The model `opencode/kimi-k2.5` is available (`kit models opencode | grep kimi-k2.5`)
|
||||
|
||||
5. For testing with a different model, edit the script or set the `MODEL` variable:
|
||||
```bash
|
||||
MODEL=anthropic/claude-sonnet-4-5 python3 scripts/acp_smoke_test.py
|
||||
```
|
||||
|
||||
The smoke test exercises the full ACP protocol: session lifecycle, streaming notifications, and tool-free prompt completion.
|
||||
+1
-2
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
@@ -788,7 +787,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
|
||||
// Load existing messages from resumed/continued sessions.
|
||||
treeSession := kitInstance.GetTreeSession()
|
||||
var messages []fantasy.Message
|
||||
var messages []kit.LLMMessage
|
||||
if treeSession != nil {
|
||||
messages = treeSession.GetLLMMessages()
|
||||
}
|
||||
|
||||
@@ -508,10 +508,7 @@ func looksLikeText(data []byte) bool {
|
||||
return true
|
||||
}
|
||||
// Check first 512 bytes (or less if file is smaller)
|
||||
sampleSize := 512
|
||||
if len(data) < sampleSize {
|
||||
sampleSize = len(data)
|
||||
}
|
||||
sampleSize := min(len(data), 512)
|
||||
sample := data[:sampleSize]
|
||||
|
||||
// Count non-printable characters
|
||||
|
||||
+13
-39
@@ -20,7 +20,7 @@ import (
|
||||
// queueItem holds a prompt and optional image attachments for the execution queue.
|
||||
type queueItem struct {
|
||||
Prompt string
|
||||
Files []fantasy.FilePart
|
||||
Files []kit.LLMFilePart
|
||||
}
|
||||
|
||||
// App is the application-layer orchestrator. It owns the agentic loop,
|
||||
@@ -82,7 +82,7 @@ type App struct {
|
||||
|
||||
// New creates a new App with the provided options and pre-loaded messages.
|
||||
// initialMessages may be nil or empty for a fresh session.
|
||||
func New(opts Options, initialMessages []fantasy.Message) *App {
|
||||
func New(opts Options, initialMessages []kit.LLMMessage) *App {
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
return &App{
|
||||
opts: opts,
|
||||
@@ -126,9 +126,8 @@ func (a *App) Run(prompt string) int {
|
||||
// If the app is idle the prompt executes immediately; otherwise it is queued.
|
||||
// Returns the current queue depth (0 = started immediately, >0 = queued).
|
||||
//
|
||||
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
|
||||
// to fantasy.FilePart).
|
||||
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
@@ -314,12 +313,12 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) AddContextMessage(text string) {
|
||||
msg := fantasy.NewUserMessage(text)
|
||||
a.store.Add(msg)
|
||||
kitMsg := fantasy.NewUserMessage(text)
|
||||
a.store.Add(kitMsg)
|
||||
|
||||
// Persist to tree session if active.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
_, _ = ts.AppendLLMMessage(msg)
|
||||
_, _ = ts.AppendLLMMessage(fantasy.NewUserMessage(text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,7 +612,7 @@ func (a *App) runQueueBatch(items []queueItem) {
|
||||
// executeStep runs a single agentic step by delegating to the SDK's
|
||||
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
|
||||
// session persistence, hooks, extension events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) (*kit.TurnResult, error) {
|
||||
// Test hook: bypass SDK entirely.
|
||||
if a.opts.PromptFunc != nil {
|
||||
return a.opts.PromptFunc(ctx, prompt)
|
||||
@@ -637,7 +636,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, fantasyFilePartsToKit(files))
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, prompt, files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, prompt)
|
||||
}
|
||||
@@ -646,7 +645,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(kitMessagesToFantasy(result.Messages))
|
||||
a.store.Replace(result.Messages)
|
||||
|
||||
// Update usage tracker. If per-step usage was already recorded from
|
||||
// StepUsageEvent callbacks, avoid double-counting totals.
|
||||
@@ -699,7 +698,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, fantasyFilePartsToKit(item.Files))
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, item.Prompt)
|
||||
}
|
||||
@@ -716,7 +715,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, fantasyFilePartsToKit(item.Files))
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -730,7 +729,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(kitMessagesToFantasy(result.Messages))
|
||||
a.store.Replace(result.Messages)
|
||||
|
||||
// Update usage tracker (using last item's prompt for fallback estimation).
|
||||
// If per-step usage was already recorded from StepUsageEvent callbacks,
|
||||
@@ -1083,28 +1082,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package app
|
||||
|
||||
import "charm.land/fantasy"
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
// StreamChunkEvent is sent by the app layer when a streaming text delta arrives
|
||||
// from the LLM. Each chunk contains an incremental portion of the response.
|
||||
@@ -118,8 +118,8 @@ type SpinnerEvent struct {
|
||||
// MessageCreatedEvent is sent when a new message is added to the message store.
|
||||
// This allows the TUI to stay in sync with the conversation history.
|
||||
type MessageCreatedEvent struct {
|
||||
// Message is the fantasy message that was added to the store.
|
||||
Message fantasy.Message
|
||||
// Message is the message that was added to the store.
|
||||
Message kit.LLMMessage
|
||||
}
|
||||
|
||||
// CompactCompleteEvent is sent when a /compact operation finishes successfully.
|
||||
|
||||
@@ -3,14 +3,14 @@ package app
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"charm.land/fantasy"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// MessageStore is a thread-safe in-memory store for the conversation history.
|
||||
// On-disk persistence is handled by the TreeManager at the app/SDK layer.
|
||||
type MessageStore struct {
|
||||
mu sync.RWMutex
|
||||
messages []fantasy.Message
|
||||
messages []kit.LLMMessage
|
||||
}
|
||||
|
||||
// NewMessageStore creates an empty MessageStore.
|
||||
@@ -20,14 +20,14 @@ func NewMessageStore() *MessageStore {
|
||||
|
||||
// NewMessageStoreWithMessages creates a MessageStore pre-populated with the
|
||||
// given messages. This is used when loading an existing session at startup.
|
||||
func NewMessageStoreWithMessages(msgs []fantasy.Message) *MessageStore {
|
||||
cp := make([]fantasy.Message, len(msgs))
|
||||
func NewMessageStoreWithMessages(msgs []kit.LLMMessage) *MessageStore {
|
||||
cp := make([]kit.LLMMessage, len(msgs))
|
||||
copy(cp, msgs)
|
||||
return &MessageStore{messages: cp}
|
||||
}
|
||||
|
||||
// Add appends a single message to the store.
|
||||
func (s *MessageStore) Add(msg fantasy.Message) {
|
||||
func (s *MessageStore) Add(msg kit.LLMMessage) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.messages = append(s.messages, msg)
|
||||
@@ -36,22 +36,22 @@ func (s *MessageStore) Add(msg fantasy.Message) {
|
||||
// Replace replaces the entire message history with the given slice. This is
|
||||
// used after an agent step returns the full updated conversation (including
|
||||
// tool calls and results).
|
||||
func (s *MessageStore) Replace(msgs []fantasy.Message) {
|
||||
func (s *MessageStore) Replace(msgs []kit.LLMMessage) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cp := make([]fantasy.Message, len(msgs))
|
||||
cp := make([]kit.LLMMessage, len(msgs))
|
||||
copy(cp, msgs)
|
||||
s.messages = cp
|
||||
}
|
||||
|
||||
// GetAll returns a snapshot copy of the current message slice.
|
||||
// The returned slice is safe to modify without affecting the store.
|
||||
func (s *MessageStore) GetAll() []fantasy.Message {
|
||||
func (s *MessageStore) GetAll() []kit.LLMMessage {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cp := make([]fantasy.Message, len(s.messages))
|
||||
cp := make([]kit.LLMMessage, len(s.messages))
|
||||
copy(cp, s.messages)
|
||||
return cp
|
||||
}
|
||||
|
||||
@@ -4,16 +4,29 @@ import (
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// makeTextMsg builds a minimal fantasy.Message with a single TextPart.
|
||||
func makeTextMsg(role, text string) fantasy.Message {
|
||||
return fantasy.Message{
|
||||
Role: fantasy.MessageRole(role),
|
||||
// makeTextMsg builds a minimal kit.LLMMessage using fantasy.NewUserMessage
|
||||
// or constructing with the given role.
|
||||
func makeTextMsg(role, text string) kit.LLMMessage {
|
||||
return kit.LLMMessage{
|
||||
Role: kit.LLMMessageRole(role),
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
|
||||
}
|
||||
}
|
||||
|
||||
// textOf extracts the plain text from an LLMMessage for assertions.
|
||||
func textOf(msg kit.LLMMessage) string {
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
return tp.Text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// NewMessageStore / NewMessageStoreWithMessages
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -29,7 +42,7 @@ func TestNewMessageStore_empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
|
||||
msgs := []fantasy.Message{
|
||||
msgs := []kit.LLMMessage{
|
||||
makeTextMsg("user", "hello"),
|
||||
makeTextMsg("assistant", "hi"),
|
||||
}
|
||||
@@ -42,7 +55,7 @@ func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
|
||||
// NewMessageStoreWithMessages must deep-copy the slice so that external
|
||||
// modifications don't affect the store.
|
||||
func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
|
||||
msgs := []fantasy.Message{makeTextMsg("user", "hello")}
|
||||
msgs := []kit.LLMMessage{makeTextMsg("user", "hello")}
|
||||
s := NewMessageStoreWithMessages(msgs)
|
||||
|
||||
// Mutate the source slice.
|
||||
@@ -52,9 +65,8 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(got))
|
||||
}
|
||||
tp, ok := got[0].Content[0].(fantasy.TextPart)
|
||||
if !ok || tp.Text != "hello" {
|
||||
t.Fatalf("store was mutated by external slice change; got %q", tp.Text)
|
||||
if textOf(got[0]) != "hello" {
|
||||
t.Fatalf("store was mutated by external slice change; got %q", textOf(got[0]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +92,8 @@ func TestAdd_preservesOrder(t *testing.T) {
|
||||
}
|
||||
got := s.GetAll()
|
||||
for i, expected := range texts {
|
||||
tp, ok := got[i].Content[0].(fantasy.TextPart)
|
||||
if !ok || tp.Text != expected {
|
||||
t.Fatalf("message[%d]: expected %q, got %q", i, expected, tp.Text)
|
||||
if textOf(got[i]) != expected {
|
||||
t.Fatalf("message[%d]: expected %q, got %q", i, expected, textOf(got[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +106,7 @@ func TestReplace_swapsHistory(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s.Add(makeTextMsg("user", "old"))
|
||||
|
||||
replacement := []fantasy.Message{
|
||||
replacement := []kit.LLMMessage{
|
||||
makeTextMsg("user", "new1"),
|
||||
makeTextMsg("assistant", "new2"),
|
||||
}
|
||||
@@ -105,25 +116,22 @@ func TestReplace_swapsHistory(t *testing.T) {
|
||||
t.Fatalf("expected 2 messages after replace, got %d", s.Len())
|
||||
}
|
||||
got := s.GetAll()
|
||||
tp0, _ := got[0].Content[0].(fantasy.TextPart)
|
||||
tp1, _ := got[1].Content[0].(fantasy.TextPart)
|
||||
if tp0.Text != "new1" || tp1.Text != "new2" {
|
||||
t.Fatalf("unexpected messages after replace: %q %q", tp0.Text, tp1.Text)
|
||||
if textOf(got[0]) != "new1" || textOf(got[1]) != "new2" {
|
||||
t.Fatalf("unexpected messages after replace: %q %q", textOf(got[0]), textOf(got[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Replace must deep-copy the incoming slice.
|
||||
func TestReplace_isolatesInput(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
replacement := []fantasy.Message{makeTextMsg("user", "original")}
|
||||
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
|
||||
s.Replace(replacement)
|
||||
|
||||
replacement[0] = makeTextMsg("user", "mutated")
|
||||
|
||||
got := s.GetAll()
|
||||
tp, _ := got[0].Content[0].(fantasy.TextPart)
|
||||
if tp.Text != "original" {
|
||||
t.Fatalf("store was mutated by external slice change after Replace; got %q", tp.Text)
|
||||
if textOf(got[0]) != "original" {
|
||||
t.Fatalf("store was mutated by external slice change after Replace; got %q", textOf(got[0]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +148,8 @@ func TestGetAll_returnsCopy(t *testing.T) {
|
||||
got[0] = makeTextMsg("user", "mutated")
|
||||
|
||||
internal := s.GetAll()
|
||||
tp, _ := internal[0].Content[0].(fantasy.TextPart)
|
||||
if tp.Text != "hello" {
|
||||
t.Fatalf("GetAll returned non-copy; store was mutated to %q", tp.Text)
|
||||
if textOf(internal[0]) != "hello" {
|
||||
t.Fatalf("GetAll returned non-copy; store was mutated to %q", textOf(internal[0]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,9 +186,8 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
|
||||
t.Fatalf("expected 1 message after Clear+Add, got %d", s.Len())
|
||||
}
|
||||
got := s.GetAll()
|
||||
tp, _ := got[0].Content[0].(fantasy.TextPart)
|
||||
if tp.Text != "after" {
|
||||
t.Fatalf("expected %q, got %q", "after", tp.Text)
|
||||
if textOf(got[0]) != "after" {
|
||||
t.Fatalf("expected %q, got %q", "after", textOf(got[0]))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package ui
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,9 +11,6 @@ import (
|
||||
"github.com/indaco/herald"
|
||||
)
|
||||
|
||||
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
|
||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
// MessageType represents different categories of messages displayed in the UI,
|
||||
// each with distinct visual styling and formatting rules.
|
||||
type MessageType int
|
||||
@@ -443,15 +439,3 @@ func createTypography(theme Theme) *herald.Typography {
|
||||
func (r *MessageRenderer) UpdateTheme() {
|
||||
r.ty = createTypography(GetTheme())
|
||||
}
|
||||
|
||||
// removeBlankLines removes lines that are visually blank from rendered output.
|
||||
func removeBlankLines(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
filtered := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(filtered, "\n")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
@@ -20,6 +19,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// appState represents the current state of the parent TUI model.
|
||||
@@ -105,7 +105,7 @@ type AppController interface {
|
||||
// Behaves like Run but includes file parts (e.g. clipboard images)
|
||||
// alongside the text. Returns the current queue depth (0 = started
|
||||
// immediately, >0 = queued).
|
||||
RunWithFiles(prompt string, files []fantasy.FilePart) int
|
||||
RunWithFiles(prompt string, files []kit.LLMFilePart) int
|
||||
// Steer injects a steering message into the currently running agent
|
||||
// turn. If the agent is busy, the message is delivered between steps
|
||||
// (after current tool finishes, before next LLM call). If idle, the
|
||||
@@ -1233,10 +1233,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
processedText = ProcessFileAttachments(msg.Text, m.cwd)
|
||||
}
|
||||
|
||||
// Convert image attachments to fantasy.FilePart for the app layer.
|
||||
var fileParts []fantasy.FilePart
|
||||
// Convert image attachments to kit.LLMFilePart for the app layer.
|
||||
var fileParts []kit.LLMFilePart
|
||||
for _, img := range msg.Images {
|
||||
fileParts = append(fileParts, fantasy.FilePart{
|
||||
fileParts = append(fileParts, kit.LLMFilePart{
|
||||
Data: img.Data,
|
||||
MediaType: img.MediaType,
|
||||
})
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -70,7 +70,7 @@ func (s *stubAppController) AddContextMessage(_ string) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) int {
|
||||
func (s *stubAppController) RunWithFiles(prompt string, _ []kit.LLMFilePart) int {
|
||||
s.runCalls = append(s.runCalls, prompt)
|
||||
return s.queueLen
|
||||
}
|
||||
|
||||
+8
-11
@@ -5,8 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/compaction"
|
||||
)
|
||||
|
||||
@@ -140,7 +138,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, fantasyToLLMMessages(messages), opts)
|
||||
return m.applyCustomCompaction(hookResult.Summary, messages, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,12 +180,11 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
|
||||
// custom summary. It still determines the cut point and persists a
|
||||
// CompactionEntry.
|
||||
func (m *Kit) applyCustomCompaction(summary string, messages []LLMMessage, opts *CompactionOptions) (*CompactionResult, error) {
|
||||
fantasyMessages := llmMessagesToFantasy(messages)
|
||||
originalTokens := compaction.EstimateMessageTokens(fantasyMessages)
|
||||
originalTokens := compaction.EstimateMessageTokens(messages)
|
||||
|
||||
cutPoint := compaction.FindCutPoint(fantasyMessages, opts.KeepRecentTokens)
|
||||
cutPoint := compaction.FindCutPoint(messages, opts.KeepRecentTokens)
|
||||
if cutPoint == 0 {
|
||||
cutPoint = len(fantasyMessages) - 1
|
||||
cutPoint = len(messages) - 1
|
||||
if cutPoint < 1 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -200,11 +197,11 @@ func (m *Kit) applyCustomCompaction(summary string, messages []LLMMessage, opts
|
||||
}
|
||||
|
||||
// Estimate new token count.
|
||||
summaryTokens := compaction.EstimateMessageTokens([]fantasy.Message{{
|
||||
Role: "system",
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: summary}},
|
||||
summaryTokens := compaction.EstimateMessageTokens([]LLMMessage{{
|
||||
Role: LLMRoleSystem,
|
||||
Content: []LLMMessagePart{LLMTextPart{Text: summary}},
|
||||
}})
|
||||
recentTokens := compaction.EstimateMessageTokens(fantasyMessages[cutPoint:])
|
||||
recentTokens := compaction.EstimateMessageTokens(messages[cutPoint:])
|
||||
compactedTokens := summaryTokens + recentTokens
|
||||
|
||||
result := &CompactionResult{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
@@ -246,12 +247,19 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
if runner.HasHandlers(extensions.ContextPrepare) {
|
||||
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
|
||||
// Convert LLM message slice to extension ContextMessage slice.
|
||||
// Extract plain text from each message for the extension API.
|
||||
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
|
||||
for i, msg := range h.Messages {
|
||||
var sb strings.Builder
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(LLMTextPart); ok {
|
||||
sb.WriteString(tp.Text)
|
||||
}
|
||||
}
|
||||
extMsgs[i] = extensions.ContextMessage{
|
||||
Index: i,
|
||||
Role: string(msg.Role),
|
||||
Content: msg.Content,
|
||||
Content: sb.String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,19 +276,19 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
// Reuse original message (preserves original role and content).
|
||||
rebuilt = append(rebuilt, h.Messages[cm.Index])
|
||||
} else {
|
||||
// New message injected by extension.
|
||||
role := LLMMessageRoleUser
|
||||
// New message injected by extension — construct from role + text.
|
||||
role := LLMRoleUser
|
||||
switch cm.Role {
|
||||
case "assistant":
|
||||
role = LLMMessageRoleAssistant
|
||||
role = LLMRoleAssistant
|
||||
case "system":
|
||||
role = LLMMessageRoleSystem
|
||||
role = LLMRoleSystem
|
||||
case "tool":
|
||||
role = LLMMessageRoleTool
|
||||
role = LLMRoleTool
|
||||
}
|
||||
rebuilt = append(rebuilt, LLMMessage{
|
||||
Role: role,
|
||||
Content: cm.Content,
|
||||
Content: []LLMMessagePart{LLMTextPart{Text: cm.Content}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -1196,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: fantasyToLLMMessages(messages)}); hookResult != nil && hookResult.Messages != nil {
|
||||
messages = llmToFantasyMessages(hookResult.Messages)
|
||||
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
|
||||
messages = hookResult.Messages
|
||||
}
|
||||
|
||||
sentCount := len(messages)
|
||||
@@ -1259,12 +1259,12 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
Response: responseText,
|
||||
StopReason: stopReason,
|
||||
SessionID: m.GetSessionID(),
|
||||
Messages: fantasyToLLMMessages(result.ConversationMessages),
|
||||
Messages: result.ConversationMessages,
|
||||
}
|
||||
totalUsage := fantasyUsageToLLM(result.TotalUsage)
|
||||
totalUsage := result.TotalUsage
|
||||
turnResult.TotalUsage = &totalUsage
|
||||
if result.FinalResponse != nil {
|
||||
finalUsage := fantasyUsageToLLM(result.FinalResponse.Usage)
|
||||
finalUsage := result.FinalResponse.Usage
|
||||
turnResult.FinalUsage = &finalUsage
|
||||
}
|
||||
|
||||
@@ -1435,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, llmFilePartsToFantasy(files)...),
|
||||
fantasy.NewUserMessage(message, files...),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
}
|
||||
+65
-83
@@ -2,7 +2,6 @@ package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
@@ -128,67 +127,74 @@ type ModelsRegistry = models.ModelsRegistry
|
||||
type SpinnerFunc = agent.SpinnerFunc
|
||||
|
||||
// ==== LLM Types ====
|
||||
//
|
||||
// These are type aliases for the corresponding charm.land/fantasy types,
|
||||
// giving them clean LLM-prefixed names without leaking the dependency name.
|
||||
// SDK consumers can use these types without importing charm.land/fantasy directly.
|
||||
|
||||
// LLMMessageRole identifies the participant role in an LLM conversation.
|
||||
type LLMMessageRole string
|
||||
// LLMMessage represents a message in an LLM conversation, carrying a role
|
||||
// and a slice of typed content parts (text, tool calls, reasoning, etc.).
|
||||
type LLMMessage = fantasy.Message
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
// LLMMessagePart is the interface implemented by all LLM message content parts.
|
||||
type LLMMessagePart = fantasy.MessagePart
|
||||
|
||||
// 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"`
|
||||
}
|
||||
type LLMFilePart = fantasy.FilePart
|
||||
|
||||
// LLMUsage contains token usage information returned by the LLM provider.
|
||||
type LLMUsage = fantasy.Usage
|
||||
|
||||
// LLMResponse represents a complete response from the LLM provider.
|
||||
type LLMResponse = fantasy.Response
|
||||
|
||||
// LLMTextPart is a plain-text content part for constructing LLM messages.
|
||||
type LLMTextPart = fantasy.TextPart
|
||||
|
||||
// LLMReasoningPart is a reasoning/chain-of-thought content part.
|
||||
type LLMReasoningPart = fantasy.ReasoningPart
|
||||
|
||||
// LLMToolCallPart represents an LLM-initiated tool invocation within a message.
|
||||
type LLMToolCallPart = fantasy.ToolCallPart
|
||||
|
||||
// LLMToolResultPart represents the result of a tool execution within a message.
|
||||
type LLMToolResultPart = fantasy.ToolResultPart
|
||||
|
||||
// LLMToolResultOutputContent is the interface for tool result output content.
|
||||
type LLMToolResultOutputContent = fantasy.ToolResultOutputContent
|
||||
|
||||
// LLMToolResultOutputContentText is a text-valued tool result output.
|
||||
type LLMToolResultOutputContentText = fantasy.ToolResultOutputContentText
|
||||
|
||||
// LLMToolResultOutputContentError is an error-valued tool result output.
|
||||
type LLMToolResultOutputContentError = fantasy.ToolResultOutputContentError
|
||||
|
||||
// LLMMessageRole identifies the participant role in an LLM conversation.
|
||||
type LLMMessageRole = fantasy.MessageRole
|
||||
|
||||
// LLMFinishReason indicates why the LLM stopped generating.
|
||||
type LLMFinishReason = fantasy.FinishReason
|
||||
|
||||
// LLM role constants mirror fantasy.MessageRole* values under clean LLM-prefixed names.
|
||||
const (
|
||||
// LLMRoleUser identifies a user message.
|
||||
LLMRoleUser = fantasy.MessageRoleUser
|
||||
// LLMRoleAssistant identifies an assistant message.
|
||||
LLMRoleAssistant = fantasy.MessageRoleAssistant
|
||||
// LLMRoleSystem identifies a system message.
|
||||
LLMRoleSystem = fantasy.MessageRoleSystem
|
||||
// LLMRoleTool identifies a tool result message.
|
||||
LLMRoleTool = fantasy.MessageRoleTool
|
||||
)
|
||||
|
||||
// NewLLMUserMessage constructs a user-role LLMMessage with optional file
|
||||
// attachments. It is equivalent to fantasy.NewUserMessage.
|
||||
var NewLLMUserMessage = fantasy.NewUserMessage
|
||||
|
||||
// NewLLMSystemMessage constructs a system-role LLMMessage from one or more
|
||||
// prompt strings. It is equivalent to fantasy.NewSystemMessage.
|
||||
var NewLLMSystemMessage = fantasy.NewSystemMessage
|
||||
|
||||
// ==== Compaction Types (internal/compaction/) ====
|
||||
|
||||
@@ -227,34 +233,10 @@ func LoadSystemPrompt(pathOrContent string) (string, error) {
|
||||
// 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
|
||||
return msg.ToLLMMessages()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
return message.FromLLMMessage(msg)
|
||||
}
|
||||
|
||||
+140
-61
@@ -61,37 +61,80 @@ func TestTypeExports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMMessageConcrete verifies LLMMessage is a concrete Kit-owned type
|
||||
// with no dependency on charm.land/fantasy in its definition.
|
||||
func TestLLMMessageConcrete(t *testing.T) {
|
||||
// TestLLMRoleConstants verifies the LLM role constants have the correct values.
|
||||
func TestLLMRoleConstants(t *testing.T) {
|
||||
if kit.LLMRoleUser != "user" {
|
||||
t.Errorf("LLMRoleUser = %q, want %q", kit.LLMRoleUser, "user")
|
||||
}
|
||||
if kit.LLMRoleAssistant != "assistant" {
|
||||
t.Errorf("LLMRoleAssistant = %q, want %q", kit.LLMRoleAssistant, "assistant")
|
||||
}
|
||||
if kit.LLMRoleSystem != "system" {
|
||||
t.Errorf("LLMRoleSystem = %q, want %q", kit.LLMRoleSystem, "system")
|
||||
}
|
||||
if kit.LLMRoleTool != "tool" {
|
||||
t.Errorf("LLMRoleTool = %q, want %q", kit.LLMRoleTool, "tool")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMMessageAlias verifies LLMMessage is a type alias for fantasy.Message
|
||||
// and can be used interchangeably.
|
||||
func TestLLMMessageAlias(t *testing.T) {
|
||||
// Construct an LLMMessage using alias types.
|
||||
msg := kit.LLMMessage{
|
||||
Role: kit.LLMMessageRoleUser,
|
||||
Content: "hello world",
|
||||
Role: kit.LLMRoleUser,
|
||||
Content: []kit.LLMMessagePart{
|
||||
kit.LLMTextPart{Text: "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")
|
||||
// Verify we can extract text via the part types.
|
||||
if len(msg.Content) != 1 {
|
||||
t.Fatalf("expected 1 content part, got %d", len(msg.Content))
|
||||
}
|
||||
|
||||
// All role constants should match their string values.
|
||||
if kit.LLMMessageRoleUser != "user" {
|
||||
t.Errorf("LLMMessageRoleUser = %q, want %q", kit.LLMMessageRoleUser, "user")
|
||||
tp, ok := msg.Content[0].(kit.LLMTextPart)
|
||||
if !ok {
|
||||
t.Fatal("content part is not LLMTextPart")
|
||||
}
|
||||
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")
|
||||
if tp.Text != "hello world" {
|
||||
t.Errorf("LLMTextPart.Text = %q, want %q", tp.Text, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMUsageConcrete verifies LLMUsage is a concrete Kit-owned type.
|
||||
func TestLLMUsageConcrete(t *testing.T) {
|
||||
// TestNewLLMUserMessage verifies the NewLLMUserMessage constructor works.
|
||||
func TestNewLLMUserMessage(t *testing.T) {
|
||||
msg := kit.NewLLMUserMessage("hello from user")
|
||||
if msg.Role != kit.LLMRoleUser {
|
||||
t.Errorf("NewLLMUserMessage role = %q, want %q", msg.Role, kit.LLMRoleUser)
|
||||
}
|
||||
if len(msg.Content) == 0 {
|
||||
t.Fatal("NewLLMUserMessage content is empty")
|
||||
}
|
||||
tp, ok := msg.Content[0].(kit.LLMTextPart)
|
||||
if !ok {
|
||||
t.Fatal("content[0] is not LLMTextPart")
|
||||
}
|
||||
if tp.Text != "hello from user" {
|
||||
t.Errorf("NewLLMUserMessage text = %q, want %q", tp.Text, "hello from user")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLLMSystemMessage verifies the NewLLMSystemMessage constructor works.
|
||||
func TestNewLLMSystemMessage(t *testing.T) {
|
||||
msg := kit.NewLLMSystemMessage("you are helpful")
|
||||
if msg.Role != kit.LLMRoleSystem {
|
||||
t.Errorf("NewLLMSystemMessage role = %q, want %q", msg.Role, kit.LLMRoleSystem)
|
||||
}
|
||||
if len(msg.Content) == 0 {
|
||||
t.Fatal("NewLLMSystemMessage content is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMUsageAlias verifies LLMUsage is a type alias for fantasy.Usage
|
||||
// and carries the correct fields.
|
||||
func TestLLMUsageAlias(t *testing.T) {
|
||||
u := kit.LLMUsage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
@@ -107,36 +150,23 @@ func TestLLMUsageConcrete(t *testing.T) {
|
||||
t.Errorf("LLMUsage.TotalTokens = %d, want 150", u.TotalTokens)
|
||||
}
|
||||
|
||||
// Verify JSON marshaling uses snake_case.
|
||||
// Verify JSON marshaling uses snake_case (inherited from fantasy.Usage tags).
|
||||
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)
|
||||
jsonStr := string(data)
|
||||
if jsonStr == "" {
|
||||
t.Error("LLMUsage JSON is empty")
|
||||
}
|
||||
// Check that input_tokens key is present.
|
||||
if !containsStr(jsonStr, `"input_tokens":100`) {
|
||||
t.Errorf("LLMUsage JSON missing input_tokens: %s", jsonStr)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestLLMFilePartAlias verifies LLMFilePart is a type alias for fantasy.FilePart.
|
||||
func TestLLMFilePartAlias(t *testing.T) {
|
||||
fp := kit.LLMFilePart{
|
||||
Filename: "screenshot.png",
|
||||
Data: []byte{0x89, 0x50, 0x4E, 0x47},
|
||||
@@ -151,6 +181,47 @@ func TestLLMFilePartConcrete(t *testing.T) {
|
||||
if len(fp.Data) != 4 {
|
||||
t.Errorf("LLMFilePart.Data len = %d, want 4", len(fp.Data))
|
||||
}
|
||||
|
||||
// Verify it can be used as a file part for constructing user messages.
|
||||
msg := kit.NewLLMUserMessage("see this image", fp)
|
||||
if msg.Role != kit.LLMRoleUser {
|
||||
t.Errorf("message role = %q, want user", msg.Role)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMPartTypesAlias verifies all the part type aliases compile and work.
|
||||
func TestLLMPartTypesAlias(t *testing.T) {
|
||||
// LLMTextPart
|
||||
tp := kit.LLMTextPart{Text: "plain text"}
|
||||
if tp.Text != "plain text" {
|
||||
t.Errorf("LLMTextPart.Text = %q", tp.Text)
|
||||
}
|
||||
|
||||
// LLMReasoningPart
|
||||
rp := kit.LLMReasoningPart{Text: "I think therefore"}
|
||||
if rp.Text != "I think therefore" {
|
||||
t.Errorf("LLMReasoningPart.Text = %q", rp.Text)
|
||||
}
|
||||
|
||||
// LLMToolCallPart
|
||||
tc := kit.LLMToolCallPart{
|
||||
ToolCallID: "call-1",
|
||||
ToolName: "bash",
|
||||
Input: `{"cmd":"echo hi"}`,
|
||||
}
|
||||
if tc.ToolCallID != "call-1" {
|
||||
t.Errorf("LLMToolCallPart.ToolCallID = %q", tc.ToolCallID)
|
||||
}
|
||||
|
||||
// LLMToolResultPart
|
||||
tro := kit.LLMToolResultOutputContentText{Text: "output text"}
|
||||
tr := kit.LLMToolResultPart{
|
||||
ToolCallID: "call-1",
|
||||
Output: tro,
|
||||
}
|
||||
if tr.ToolCallID != "call-1" {
|
||||
t.Errorf("LLMToolResultPart.ToolCallID = %q", tr.ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertToLLMMessages verifies round-trip conversion preserves content.
|
||||
@@ -163,20 +234,25 @@ func TestConvertToLLMMessages(t *testing.T) {
|
||||
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].Role != kit.LLMRoleUser {
|
||||
t.Errorf("converted Role = %q, want %q", llmMsgs[0].Role, kit.LLMRoleUser)
|
||||
}
|
||||
if llmMsgs[0].Content != "what is 2+2?" {
|
||||
t.Errorf("converted Content = %q, want %q", llmMsgs[0].Content, "what is 2+2?")
|
||||
// Check text is preserved in content parts.
|
||||
found := false
|
||||
for _, part := range llmMsgs[0].Content {
|
||||
if tp, ok := part.(kit.LLMTextPart); ok && tp.Text == "what is 2+2?" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("text content not found in converted LLMMessage")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertFromLLMMessage verifies LLMMessage → Message conversion.
|
||||
func TestConvertFromLLMMessage(t *testing.T) {
|
||||
llm := kit.LLMMessage{
|
||||
Role: kit.LLMMessageRoleAssistant,
|
||||
Content: "the answer is 4",
|
||||
}
|
||||
llm := kit.NewLLMUserMessage("the answer is 4")
|
||||
llm.Role = kit.LLMRoleAssistant
|
||||
msg := kit.ConvertFromLLMMessage(llm)
|
||||
if msg.Role != kit.RoleAssistant {
|
||||
t.Errorf("converted Role = %q, want %q", msg.Role, kit.RoleAssistant)
|
||||
@@ -186,13 +262,16 @@ func TestConvertFromLLMMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"}
|
||||
// containsStr is a tiny helper to avoid importing strings in test.
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && indexStr(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func indexStr(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ACP smoke test — drives `kit acp` over JSON-RPC 2.0 stdio.
|
||||
|
||||
Protocol flow:
|
||||
1. session/new → get sessionId
|
||||
2. session/set_model → set opencode/kimi-k2.5
|
||||
3. session/prompt → "What is 2+2? Answer in one sentence."
|
||||
4. Collect session updates until done
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
|
||||
KIT_BIN = os.path.join(os.path.dirname(__file__), "..", "output", "kit")
|
||||
MODEL = "opencode/kimi-k2.5"
|
||||
CWD = os.path.expanduser("~")
|
||||
TIMEOUT = 60 # seconds to wait for the prompt to complete
|
||||
|
||||
|
||||
def rpc(method, params, req_id):
|
||||
return json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n"
|
||||
|
||||
|
||||
def send(proc, line):
|
||||
print(f"\n→ SEND {line.strip()}", flush=True)
|
||||
proc.stdin.write(line)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def read_responses(proc, collected, done_event):
|
||||
"""Read newline-delimited JSON from stdout until process exits."""
|
||||
for raw in proc.stdout:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
print(f" [non-JSON stdout]: {raw}", flush=True)
|
||||
continue
|
||||
|
||||
collected.append(msg)
|
||||
|
||||
# Pretty-print condensed
|
||||
if "result" in msg:
|
||||
result = msg["result"]
|
||||
print(f"← RESP id={msg.get('id')} result={json.dumps(result)[:200]}", flush=True)
|
||||
# Prompt complete when we get a stopReason on id=3
|
||||
if msg.get("id") == 3 and "stopReason" in result:
|
||||
done_event.set()
|
||||
elif "error" in msg:
|
||||
print(f"← ERROR id={msg.get('id')} {json.dumps(msg['error'])}", flush=True)
|
||||
# If it's the prompt call that errored, unblock
|
||||
if msg.get("id") == 3:
|
||||
done_event.set()
|
||||
elif "method" in msg:
|
||||
# Notification / session update
|
||||
m = msg.get("method", "")
|
||||
p = msg.get("params", {})
|
||||
if m in ("session/update", "session/updated"):
|
||||
update = p.get("update", {})
|
||||
stype = update.get("sessionUpdate") or update.get("type", "?")
|
||||
content = update.get("content", {})
|
||||
if stype == "agent_thought_chunk":
|
||||
print(f" [thinking] {content.get('text','')}", end="", flush=True)
|
||||
elif stype == "agent_message_chunk":
|
||||
print(f" [response] {content.get('text','')}", end="", flush=True)
|
||||
else:
|
||||
print(f"\n [update/{stype}] {json.dumps(update)[:200]}", flush=True)
|
||||
else:
|
||||
print(f"\n← NOTIF {m} {json.dumps(p)[:200]}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Starting: {KIT_BIN} acp -m {MODEL}", flush=True)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[KIT_BIN, "acp", "-m", MODEL],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
collected = []
|
||||
done_event = threading.Event()
|
||||
|
||||
reader = threading.Thread(target=read_responses, args=(proc, collected, done_event), daemon=True)
|
||||
reader.start()
|
||||
|
||||
stderr_lines = []
|
||||
def read_stderr():
|
||||
for line in proc.stderr:
|
||||
line = line.rstrip()
|
||||
stderr_lines.append(line)
|
||||
if line:
|
||||
print(f" [stderr] {line}", flush=True)
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
|
||||
time.sleep(0.3) # let the process initialise
|
||||
|
||||
# 1. session/new
|
||||
send(proc, rpc("session/new", {"cwd": CWD, "mcpServers": []}, 1))
|
||||
time.sleep(1.0)
|
||||
|
||||
session_id = None
|
||||
for msg in collected:
|
||||
if msg.get("id") == 1 and "result" in msg:
|
||||
session_id = msg["result"].get("sessionId")
|
||||
break
|
||||
|
||||
if not session_id:
|
||||
print("\n✗ FAIL: did not get sessionId from session/new", flush=True)
|
||||
proc.terminate()
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n✓ Got sessionId: {session_id}", flush=True)
|
||||
|
||||
# 2. session/set_model (model already set via -m flag, but exercise the RPC)
|
||||
send(proc, rpc("session/set_model", {"sessionId": session_id, "modelId": MODEL}, 2))
|
||||
time.sleep(0.5)
|
||||
|
||||
# 3. session/prompt
|
||||
prompt_params = {
|
||||
"sessionId": session_id,
|
||||
"prompt": [{"type": "text", "text": "What is 2+2? Answer in one sentence."}],
|
||||
}
|
||||
send(proc, rpc("session/prompt", prompt_params, 3))
|
||||
|
||||
# Wait for finished update or timeout
|
||||
if not done_event.wait(timeout=TIMEOUT):
|
||||
print(f"\n✗ FAIL: timed out after {TIMEOUT}s waiting for finished update", flush=True)
|
||||
proc.terminate()
|
||||
sys.exit(1)
|
||||
|
||||
# Check we got a successful prompt response
|
||||
prompt_resp = next((m for m in collected if m.get("id") == 3), None)
|
||||
if prompt_resp and "error" in prompt_resp:
|
||||
print(f"\n✗ FAIL: prompt returned error: {prompt_resp['error']}", flush=True)
|
||||
proc.terminate()
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✓ SMOKE TEST PASSED", flush=True)
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user