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:
Ed Zynda
2026-03-31 14:26:49 +03:00
parent ac8ee6525d
commit d79eb1f0fa
17 changed files with 493 additions and 343 deletions
+37
View File
@@ -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
View File
@@ -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()
}
+1 -4
View File
@@ -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
View File
@@ -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
}
+3 -3
View File
@@ -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.
+9 -9
View File
@@ -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
}
+33 -27
View File
@@ -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]))
}
}
-16
View File
@@ -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")
}
+5 -5
View File
@@ -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,
})
+2 -2
View File
@@ -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
View File
@@ -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{
+15 -7
View File
@@ -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
View File
@@ -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...),
})
}
-68
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+155
View File
@@ -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()