diff --git a/.kit/prompts/acp-smoke-test.md b/.kit/prompts/acp-smoke-test.md new file mode 100644 index 00000000..72c9e0ff --- /dev/null +++ b/.kit/prompts/acp-smoke-test.md @@ -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. diff --git a/cmd/root.go b/cmd/root.go index 7efccce7..c8ef1ddc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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() } diff --git a/internal/acpserver/agent.go b/internal/acpserver/agent.go index 868a8068..5cd7ba0e 100644 --- a/internal/acpserver/agent.go +++ b/internal/acpserver/agent.go @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index de9d6524..218ba8af 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 -} diff --git a/internal/app/events.go b/internal/app/events.go index 3838a5a5..5ffc94f3 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -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. diff --git a/internal/app/messages.go b/internal/app/messages.go index 9ea96ed4..0ec94d0e 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -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 } diff --git a/internal/app/messages_test.go b/internal/app/messages_test.go index 7c4bad8d..6cfa87de 100644 --- a/internal/app/messages_test.go +++ b/internal/app/messages_test.go @@ -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])) } } diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 1f59331c..2c022b38 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -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") -} diff --git a/internal/ui/model.go b/internal/ui/model.go index 443c1034..0afbaefa 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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, }) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index be6fac04..0294b49a 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -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 } diff --git a/pkg/kit/compaction.go b/pkg/kit/compaction.go index e2ed9d86..6a6ed540 100644 --- a/pkg/kit/compaction.go +++ b/pkg/kit/compaction.go @@ -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{ diff --git a/pkg/kit/extensions_bridge.go b/pkg/kit/extensions_bridge.go index 7a53fe62..b50c6b9d 100644 --- a/pkg/kit/extensions_bridge.go +++ b/pkg/kit/extensions_bridge.go @@ -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}}, }) } } diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 56013443..70beb138 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -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...), }) } diff --git a/pkg/kit/llm_convert.go b/pkg/kit/llm_convert.go deleted file mode 100644 index 6015caff..00000000 --- a/pkg/kit/llm_convert.go +++ /dev/null @@ -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 -} diff --git a/pkg/kit/types.go b/pkg/kit/types.go index 606847b3..021e3cc7 100644 --- a/pkg/kit/types.go +++ b/pkg/kit/types.go @@ -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) } diff --git a/pkg/kit/types_test.go b/pkg/kit/types_test.go index 05071993..2fbf5c95 100644 --- a/pkg/kit/types_test.go +++ b/pkg/kit/types_test.go @@ -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 } diff --git a/scripts/acp_smoke_test.py b/scripts/acp_smoke_test.py new file mode 100755 index 00000000..d3ca9bad --- /dev/null +++ b/scripts/acp_smoke_test.py @@ -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()