remove dead code and add Plan 10 for full app-as-SDK-consumer integration

Delete legacy session files (manager.go, session.go) and unused
ParseModelName() — all orphaned after Plans 00-09. Add Plan 10 to
close all deferred items: app uses kit.New(), executeStep() delegates
to kit.PromptResult(), extension observation events route through SDK
EventBus.
This commit is contained in:
Ed Zynda
2026-02-27 13:33:19 +03:00
parent 470ec43636
commit 35452cc21b
5 changed files with 719 additions and 351 deletions
-12
View File
@@ -81,15 +81,3 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error
return agent, nil
}
// ParseModelName extracts provider and model name from a model string.
// Model strings are formatted as "provider/model" (e.g., "anthropic/claude-sonnet-4-5-20250929").
// The legacy "provider:model" format is also accepted for backward compatibility.
// If the string cannot be parsed, returns "unknown" for both provider and model.
func ParseModelName(modelString string) (provider, model string) {
p, m, err := models.ParseModelString(modelString)
if err != nil {
return "unknown", "unknown"
}
return p, m
}
-152
View File
@@ -1,152 +0,0 @@
package session
import (
"fmt"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
)
// Manager manages session state and auto-saving functionality.
// It provides thread-safe operations for managing a conversation session,
// including automatic persistence to disk after each modification.
type Manager struct {
session *Session
filePath string
mutex sync.RWMutex
}
// NewManager creates a new session manager with a fresh session.
func NewManager(filePath string) *Manager {
return &Manager{
session: NewSession(),
filePath: filePath,
}
}
// NewManagerWithSession creates a new session manager with an existing session.
func NewManagerWithSession(session *Session, filePath string) *Manager {
return &Manager{
session: session,
filePath: filePath,
}
}
// AddMessage adds a fantasy message to the session and auto-saves.
func (m *Manager) AddMessage(msg fantasy.Message) error {
m.mutex.Lock()
defer m.mutex.Unlock()
sessionMsg := ConvertFromFantasyMessage(msg)
m.session.AddMessage(sessionMsg)
if m.filePath != "" {
return m.session.SaveToFile(m.filePath)
}
return nil
}
// AddMessages adds multiple fantasy messages to the session and auto-saves.
func (m *Manager) AddMessages(msgs []fantasy.Message) error {
m.mutex.Lock()
defer m.mutex.Unlock()
for _, msg := range msgs {
sessionMsg := ConvertFromFantasyMessage(msg)
m.session.AddMessage(sessionMsg)
}
if m.filePath != "" {
return m.session.SaveToFile(m.filePath)
}
return nil
}
// ReplaceAllMessages replaces all messages in the session with the provided messages.
func (m *Manager) ReplaceAllMessages(msgs []fantasy.Message) error {
m.mutex.Lock()
defer m.mutex.Unlock()
// Clear existing messages
m.session.Messages = []message.Message{}
// Add all new messages
for _, msg := range msgs {
sessionMsg := ConvertFromFantasyMessage(msg)
m.session.AddMessage(sessionMsg)
}
if m.filePath != "" {
return m.session.SaveToFile(m.filePath)
}
return nil
}
// SetMetadata sets the session metadata.
func (m *Manager) SetMetadata(metadata Metadata) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.session.SetMetadata(metadata)
if m.filePath != "" {
return m.session.SaveToFile(m.filePath)
}
return nil
}
// GetMessages returns all messages as fantasy.Message slice.
func (m *Manager) GetMessages() []fantasy.Message {
m.mutex.RLock()
defer m.mutex.RUnlock()
var messages []fantasy.Message
for _, msg := range m.session.Messages {
messages = append(messages, msg.ToFantasyMessages()...)
}
return messages
}
// GetSession returns a copy of the current session.
func (m *Manager) GetSession() *Session {
m.mutex.RLock()
defer m.mutex.RUnlock()
sessionCopy := *m.session
sessionCopy.Messages = make([]message.Message, len(m.session.Messages))
copy(sessionCopy.Messages, m.session.Messages)
return &sessionCopy
}
// Save manually saves the session to file.
func (m *Manager) Save() error {
m.mutex.RLock()
defer m.mutex.RUnlock()
if m.filePath == "" {
return fmt.Errorf("no file path specified for session manager")
}
return m.session.SaveToFile(m.filePath)
}
// GetFilePath returns the file path for this session.
func (m *Manager) GetFilePath() string {
return m.filePath
}
// MessageCount returns the number of messages in the session.
func (m *Manager) MessageCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
return len(m.session.Messages)
}
-186
View File
@@ -1,186 +0,0 @@
package session
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
)
// Session represents a complete conversation session with metadata.
// It stores all messages exchanged during a conversation along with
// contextual information about the session such as the provider, model,
// and timestamps. Sessions can be saved to and loaded from JSON files
// for persistence across program runs.
type Session struct {
// Version indicates the session format version for compatibility
Version string `json:"version"`
// CreatedAt is the timestamp when the session was first created
CreatedAt time.Time `json:"created_at"`
// UpdatedAt is the timestamp when the session was last modified
UpdatedAt time.Time `json:"updated_at"`
// Metadata contains contextual information about the session
Metadata Metadata `json:"metadata"`
// Messages is the ordered list of all messages in this session, stored
// as custom content blocks (crush-style). Each message contains a
// heterogeneous Parts slice serialized as type-tagged JSON.
Messages []message.Message `json:"messages"`
}
// Metadata contains session metadata that provides context about the
// environment and configuration used during the conversation.
type Metadata struct {
// KitVersion is the version of KIT used for this session
KitVersion string `json:"kit_version"`
// Provider is the LLM provider used (e.g., "anthropic", "openai", "gemini")
Provider string `json:"provider"`
// Model is the specific model identifier used for the conversation
Model string `json:"model"`
}
// NewSession creates a new session with default values.
func NewSession() *Session {
return &Session{
Version: "2.0",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Messages: []message.Message{},
Metadata: Metadata{},
}
}
// AddMessage adds a message to the session.
func (s *Session) AddMessage(msg message.Message) {
if msg.ID == "" {
msg.ID = generateMessageID()
}
if msg.CreatedAt.IsZero() {
msg.CreatedAt = time.Now()
}
if msg.UpdatedAt.IsZero() {
msg.UpdatedAt = time.Now()
}
s.Messages = append(s.Messages, msg)
s.UpdatedAt = time.Now()
}
// SetMetadata sets the session metadata.
func (s *Session) SetMetadata(metadata Metadata) {
s.Metadata = metadata
s.UpdatedAt = time.Now()
}
// sessionJSON is the on-disk format with parts serialized as JSON strings.
type sessionJSON struct {
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata Metadata `json:"metadata"`
Messages []messageJSON `json:"messages"`
}
type messageJSON struct {
ID string `json:"id"`
Role string `json:"role"`
Parts json.RawMessage `json:"parts"` // type-tagged JSON array
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SaveToFile saves the session to a JSON file.
func (s *Session) SaveToFile(filePath string) error {
s.UpdatedAt = time.Now()
sj := sessionJSON{
Version: s.Version,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
Metadata: s.Metadata,
Messages: make([]messageJSON, len(s.Messages)),
}
for i, msg := range s.Messages {
parts, err := message.MarshalParts(msg.Parts)
if err != nil {
return fmt.Errorf("failed to marshal parts for message %s: %w", msg.ID, err)
}
sj.Messages[i] = messageJSON{
ID: msg.ID,
Role: string(msg.Role),
Parts: parts,
Model: msg.Model,
Provider: msg.Provider,
CreatedAt: msg.CreatedAt,
UpdatedAt: msg.UpdatedAt,
}
}
data, err := json.MarshalIndent(sj, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session: %v", err)
}
return os.WriteFile(filePath, data, 0644)
}
// LoadFromFile loads a session from a JSON file.
func LoadFromFile(filePath string) (*Session, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read session file: %v", err)
}
var sj sessionJSON
if err := json.Unmarshal(data, &sj); err != nil {
return nil, fmt.Errorf("failed to unmarshal session: %v", err)
}
session := &Session{
Version: sj.Version,
CreatedAt: sj.CreatedAt,
UpdatedAt: sj.UpdatedAt,
Metadata: sj.Metadata,
Messages: make([]message.Message, len(sj.Messages)),
}
for i, mj := range sj.Messages {
parts, err := message.UnmarshalParts(mj.Parts)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal parts for message %s: %w", mj.ID, err)
}
session.Messages[i] = message.Message{
ID: mj.ID,
Role: message.MessageRole(mj.Role),
Parts: parts,
Model: mj.Model,
Provider: mj.Provider,
CreatedAt: mj.CreatedAt,
UpdatedAt: mj.UpdatedAt,
}
}
return session, nil
}
// ConvertFromFantasyMessage converts a fantasy.Message to a message.Message.
// This function bridges between the fantasy message format and the
// session's internal message format for persistence.
func ConvertFromFantasyMessage(msg fantasy.Message) message.Message {
return message.FromFantasyMessage(msg)
}
// generateMessageID generates a unique message ID.
func generateMessageID() string {
bytes := make([]byte, 8)
_, _ = rand.Read(bytes)
return "msg_" + hex.EncodeToString(bytes)
}
+714
View File
@@ -0,0 +1,714 @@
# Plan 10: App-as-SDK-Consumer — Complete Integration
**Priority**: P4
**Effort**: High
**Goal**: Make the CLI app a full consumer of the SDK. `cmd/root.go` creates a `*Kit` via `kit.New()`. The app receives `*Kit`, calls `kit.PromptResult()`, subscribes to SDK events for TUI rendering, and extension observation events route through the SDK EventBus. This closes all deferred work from Plans 03, 05, and 09.
## Background
Plans 0009 built the SDK surface (`pkg/kit/`) but the CLI app still bypasses it for the critical path:
- `cmd/root.go` calls `SetupAgent()` directly instead of `kit.New()`
- `internal/app/app.go:executeStep()` calls `agent.GenerateWithLoopAndStreaming()` directly with 150+ lines of manual callback wiring, extension event dispatch, and session persistence — all of which the SDK already handles in `runTurn()`
- Extension observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `executeStep()`, not from the SDK
- The app receives an `AgentRunner` interface, not a `*Kit`
After this plan, `executeStep()` becomes a thin wrapper around `kit.PromptResult()`, and extension events flow through the SDK's EventBus.
### Deferred Items Resolved
| Source | What | How |
|--------|------|-----|
| Plan 03 Step 6 | App TUI subscribes to SDK events | Step 5 |
| Plan 03 Step 7 | Extension observation events forward to SDK EventBus | Step 4 |
| Plan 05 Step 6 | `executeStep()` delegates to SDK `Prompt()` | Step 6 |
| Plan 09 Phase 2 | App uses only SDK hooks, no direct runner calls | Step 6 |
## Prerequisites
- Plans 0009 (all complete)
## Step-by-Step
### Step 1: Extend Kit for CLI consumption
**Files**: `pkg/kit/kit.go`, `pkg/kit/setup.go`
The CLI needs fields that the programmatic SDK doesn't: spinner for Ollama loading, buffered debug logger, pre-loaded MCP config. Add these to `Options` and expose results via getters.
**1a. Add CLI fields to `Options`** (`pkg/kit/kit.go:48-71`):
```go
type Options struct {
// ... existing fields ...
// CLI-specific fields (ignored by programmatic SDK users)
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
ShowSpinner bool // Show loading spinner for Ollama models
SpinnerFunc agent.SpinnerFunc // Spinner implementation (nil = no spinner)
UseBufferedLogger bool // Buffer debug messages for later display
Debug bool // Enable debug logging
}
```
**1b. Add fields and getters to `Kit` struct** (`pkg/kit/kit.go:22-36`):
```go
type Kit struct {
// ... existing fields ...
extRunner *extensions.Runner
bufferedLogger *tools.BufferedDebugLogger
}
```
Getters:
```go
// GetExtRunner returns the extension runner (nil if extensions are disabled).
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
// GetAgent returns the underlying agent. Callers that need the raw agent
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
// GetTreeSession returns the current tree session manager.
// (Already exists as a method — verify it's public.)
```
**1c. Update `New()`** (`pkg/kit/kit.go:111-204`):
- If `opts.MCPConfig != nil`, skip `config.LoadAndValidateConfig()` and use it directly
- If `opts.Debug`, set `viper.Set("debug", true)`
- Pass `ShowSpinner`, `SpinnerFunc`, `UseBufferedLogger` through to `SetupAgent()`
- Store `agentResult.ExtRunner` and `agentResult.BufferedLogger` on the Kit struct
```go
// In New(), replace lines 152-176:
mcpConfig := opts.MCPConfig
if mcpConfig == nil {
var err error
mcpConfig, err = config.LoadAndValidateConfig()
if err != nil {
return nil, fmt.Errorf("failed to load MCP config: %w", err)
}
}
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
ShowSpinner: opts.ShowSpinner,
SpinnerFunc: opts.SpinnerFunc,
UseBufferedLogger: opts.UseBufferedLogger,
CoreTools: opts.Tools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
})
// Store on Kit struct:
k := &Kit{
// ... existing fields ...
extRunner: agentResult.ExtRunner,
bufferedLogger: agentResult.BufferedLogger,
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
Existing behavior is unchanged — the new fields default to zero values.
---
### Step 2: Add TurnResult and PromptResult method
**File**: `pkg/kit/kit.go`
The current `Prompt()` returns `(string, error)`, which is fine for simple SDK usage but the app needs usage stats and conversation messages. Add a richer return path.
**2a. Define TurnResult** (new, in `pkg/kit/kit.go`):
```go
// TurnResult contains the full result of a prompt turn, including usage
// statistics and the updated conversation. Use PromptResult() instead of
// Prompt() when you need access to this data.
type TurnResult struct {
// Response is the assistant's final text response.
Response string
// TotalUsage is the aggregate token usage across all steps in the turn
// (includes tool-calling loop iterations). Nil if the provider didn't
// report usage.
TotalUsage *FantasyUsage
// FinalUsage is the token usage from the last API call only. Use this
// for context window fill estimation (InputTokens + OutputTokens ≈
// current context size). Nil if unavailable.
FinalUsage *FantasyUsage
// Messages is the full updated conversation after the turn, including
// any tool call/result messages added during the agent loop.
Messages []FantasyMessage
}
```
**2b. Modify `runTurn()` to return `*TurnResult`** (`pkg/kit/kit.go:319`):
Change signature from:
```go
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (string, error)
```
To:
```go
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (*TurnResult, error)
```
Build and return `TurnResult` from the `agent.GenerateWithLoopResult`:
```go
responseText := result.FinalResponse.Content.Text()
turnResult := &TurnResult{
Response: responseText,
Messages: result.ConversationMessages,
}
if result.TotalUsage != nil {
turnResult.TotalUsage = result.TotalUsage
}
if result.FinalResponse != nil {
turnResult.FinalUsage = &result.FinalResponse.Usage
}
// ... existing event emission and persistence ...
return turnResult, nil
```
On the error path, return `nil, err` (as before, but with `*TurnResult` instead of `""`).
**2c. Update all prompt methods** to extract the string from `TurnResult`:
```go
func (m *Kit) Prompt(ctx context.Context, message string) (string, error) {
result, err := m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message),
})
if err != nil {
return "", err
}
return result.Response, nil
}
```
Same pattern for `Steer()`, `FollowUp()`, `PromptWithOptions()`, `PromptWithCallbacks()`.
**2d. Add `PromptResult()` method**:
```go
// PromptResult sends a message and returns the full turn result including
// usage statistics and conversation messages. Use this instead of Prompt()
// when you need more than just the response text.
func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) {
return m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message),
})
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
Existing `Prompt()` callers (examples, tests) are unaffected.
---
### Step 3: Migrate cmd/root.go to use kit.New()
**Files**: `cmd/root.go`, `cmd/setup.go`
Replace the manual `SetupAgent()``InitTreeSession()``BuildAppOptions()` chain with a single `kit.New()` call.
**3a. Replace agent creation** in `runNormalMode()` (`cmd/root.go:336-362`):
Before:
```go
agentResult, err := SetupAgent(ctx, AgentSetupOptions{...})
mcpAgent := agentResult.Agent
defer func() { _ = mcpAgent.Close() }()
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
```
After:
```go
// Build Kit options from CLI flags.
kitOpts := &kit.Options{
MCPConfig: mcpConfig,
ShowSpinner: true,
SpinnerFunc: spinnerFunc,
UseBufferedLogger: true,
Quiet: quietFlag,
Debug: debugMode,
NoSession: noSessionFlag,
Continue: continueFlag,
SessionPath: sessionPath,
AutoCompact: autoCompactFlag,
}
if resumeFlag {
sessions, _ := kit.ListSessions("")
if len(sessions) > 0 {
kitOpts.SessionPath = sessions[0].Path
}
}
kitInstance, err := kit.New(ctx, kitOpts)
if err != nil {
return err
}
defer kitInstance.Close()
```
**3b. Extract metadata from Kit instead of raw agent**:
```go
mcpAgent := kitInstance.GetAgent()
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
```
**3c. Get buffered logger and tree session from Kit**:
```go
bufferedLogger := kitInstance.GetBufferedLogger()
// ... display buffered debug messages ...
treeSession := kitInstance.GetTreeSession()
var messages []fantasy.Message
if treeSession != nil {
messages = treeSession.GetFantasyMessages()
}
```
**3d. Build app options using Kit**:
```go
appOpts := BuildAppOptions(mcpAgent, mcpConfig, modelName, serverNames, toolNames, kitInstance.GetExtRunner())
appOpts.TreeSession = treeSession
appOpts.Kit = kitInstance // NEW — added in Step 5
```
**3e. Extension context setup** — use Kit's extension runner:
```go
extRunner := kitInstance.GetExtRunner()
if extRunner != nil {
extRunner.SetContext(extensions.Context{...})
// Emit SessionStart
}
```
**3f. Remove the separate `kit.InitTreeSession()` call** — Kit.New() handles session creation.
**3g. Remove the `defer func() { _ = mcpAgent.Close() }()`**`kitInstance.Close()` handles cleanup.
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
# Manual: run `kit -p "hello"` to verify non-interactive mode
# Manual: run `kit` to verify interactive mode
```
The app still uses its own `executeStep()` at this point — that migrates in Step 6.
---
### Step 4: Bridge extension observation events through SDK EventBus
**File**: `pkg/kit/extensions_bridge.go`
Currently `bridgeExtensions()` only bridges `Input` and `BeforeAgentStart` (hook events). The observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `app.executeStep()` directly to the extension runner. After this step, the SDK emits them from `runTurn()`/`generate()` and the bridge forwards to extensions.
**4a. Subscribe to SDK events and forward to extension runner**:
Add to `bridgeExtensions()` (`pkg/kit/extensions_bridge.go:16`):
```go
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// ... existing Input and BeforeAgentStart hooks ...
// Forward SDK observation events to extension runner.
// These events are emitted by runTurn()/generate() and forwarded here
// so extensions see them without the app having to emit them manually.
if runner.HasHandlers(extensions.AgentStart) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnStartEvent); ok {
runner.Emit(extensions.AgentStartEvent{Prompt: ev.Prompt})
}
})
}
if runner.HasHandlers(extensions.MessageStart) {
m.Subscribe(func(e Event) {
if _, ok := e.(MessageStartEvent); ok {
runner.Emit(extensions.MessageStartEvent{})
}
})
}
if runner.HasHandlers(extensions.MessageUpdate) {
m.Subscribe(func(e Event) {
if ev, ok := e.(MessageUpdateEvent); ok {
runner.Emit(extensions.MessageUpdateEvent{Chunk: ev.Chunk})
}
})
}
if runner.HasHandlers(extensions.MessageEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(MessageEndEvent); ok {
runner.Emit(extensions.MessageEndEvent{Content: ev.Content})
}
})
}
if runner.HasHandlers(extensions.AgentEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnEndEvent); ok {
stopReason := "completed"
response := ev.Response
if ev.Error != nil {
stopReason = "error"
response = ""
}
runner.Emit(extensions.AgentEndEvent{
Response: response,
StopReason: stopReason,
})
}
})
}
}
```
**4b. Add SessionShutdown to Kit.Close()**:
In `pkg/kit/kit.go:Close()`:
```go
func (m *Kit) Close() error {
// Emit SessionShutdown for extensions.
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) {
m.extRunner.Emit(extensions.SessionShutdownEvent{})
}
if m.treeSession != nil {
_ = m.treeSession.Close()
}
return m.agent.Close()
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
At this point, extension observation events will fire from BOTH `executeStep()` (app) and the SDK bridge. This is intentional for the transition — Step 6 removes the app-side emission.
---
### Step 5: Wire app to Kit — add Kit field and SDK event → tea.Msg bridge
**Files**: `internal/app/options.go`, `internal/app/app.go`
Give the app a `*Kit` reference so it can call SDK prompt methods and subscribe to events.
**5a. Add Kit field to `app.Options`** (`internal/app/options.go:50`):
```go
import kit "github.com/mark3labs/kit/pkg/kit"
type Options struct {
// Kit is the SDK instance. When set, executeStep() delegates to
// kit.PromptResult() and events flow through SDK subscriptions.
Kit *kit.Kit
// Agent is the agent used to run the agentic loop. Required when Kit
// is nil. When Kit is set, this field is ignored (Kit owns the agent).
Agent AgentRunner
// ... rest unchanged ...
}
```
**5b. Create SDK event → tea.Msg bridge function** (`internal/app/app.go`):
```go
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
k := a.opts.Kit
var unsubs []func()
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
switch ev := e.(type) {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
case kit.ToolExecutionEndEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
case kit.ToolResultEvent:
sendFn(ToolResultEvent{
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
Result: ev.Result, IsError: ev.IsError,
})
case kit.ToolCallContentEvent:
sendFn(ToolCallContentEvent{Content: ev.Content})
case kit.ResponseEvent:
sendFn(ResponseCompleteEvent{Content: ev.Content})
case kit.MessageUpdateEvent:
sendFn(StreamChunkEvent{Content: ev.Chunk})
}
}))
return func() {
for _, unsub := range unsubs {
unsub()
}
}
}
```
**5c. Pass Kit in `cmd/root.go`**:
In the `BuildAppOptions` call or directly after:
```go
appOpts.Kit = kitInstance
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
The bridge function exists but is not called yet. Step 6 wires it in.
---
### Step 6: Migrate executeStep() to use kit.PromptResult()
**File**: `internal/app/app.go`
Replace the 150+ line `executeStep()` with a thin wrapper around `kit.PromptResult()`.
**6a. Rewrite executeStep()**:
The new `executeStep()` when `opts.Kit` is set:
```go
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
if a.opts.Kit == nil {
return a.executeStepLegacy(ctx, prompt, eventFn)
}
sendFn := func(msg tea.Msg) {
if eventFn != nil {
eventFn(msg)
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
sendFn(SpinnerEvent{Show: true})
result, err := a.opts.Kit.PromptResult(ctx, prompt)
if err != nil {
return nil, err
}
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
return &agent.GenerateWithLoopResult{
ConversationMessages: result.Messages,
}, nil
}
```
**6b. Rename existing executeStep to executeStepLegacy**:
Keep the old implementation as `executeStepLegacy()` so the transition is safe. It remains as a fallback when `opts.Kit == nil` (e.g. in tests that supply a stub `AgentRunner`).
**6c. Add `updateUsageFromTurnResult` helper**:
```go
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
if a.opts.UsageTracker == nil || result == nil {
return
}
if result.TotalUsage != nil {
inputTokens := int(result.TotalUsage.InputTokens)
outputTokens := int(result.TotalUsage.OutputTokens)
if inputTokens > 0 && outputTokens > 0 {
cacheReadTokens := int(result.TotalUsage.CacheReadTokens)
cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens)
a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
} else {
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
return
}
}
if result.FinalUsage != nil {
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
a.opts.UsageTracker.SetContextTokens(ct)
}
}
}
```
**6d. Remove extension event emission from `executeStepLegacy()`**:
Since the SDK bridge (Step 4) now forwards extension observation events, remove these direct calls from `executeStepLegacy()`:
- `extensions.AgentStart` emission (line 432-434)
- `extensions.MessageStart` emission (line 440-442)
- `extensions.MessageUpdate` emission (line 473-475)
- `extensions.MessageEnd` emission (line 496-498)
- `extensions.AgentEnd` emission (lines 482-487, 501-506)
The `Input` and `BeforeAgentStart` extensions are already handled by the SDK hooks (bridged in Plan 09). Remove those too from `executeStepLegacy()`:
- `extensions.Input` emission (lines 372-387)
- `extensions.BeforeAgentStart` emission (lines 414-429)
What remains in `executeStepLegacy()` is just the core generation call — which is now essentially the same as calling `kit.PromptResult()`.
**6e. Remove SessionShutdown from `app.Close()`**:
Since `Kit.Close()` now handles SessionShutdown (Step 4b), remove:
```go
// In app.Close() — remove:
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.SessionShutdown) {
_, _ = a.opts.Extensions.Emit(extensions.SessionShutdownEvent{})
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
# Manual: run `kit -p "list files in the current directory"` — verify tool calls render
# Manual: run `kit` in interactive mode — verify streaming, tool results, spinner
# Manual: create a .kit/extensions/ extension with AgentStart handler — verify it fires
```
---
### Step 7: Clean up dead code
**Files**: `internal/app/app.go`, `internal/app/options.go`, `internal/app/events.go`, `cmd/setup.go`
**7a. Remove `executeStepLegacy()`**:
Once confident the SDK path works, delete `executeStepLegacy()` entirely. Update `executeStep()` to remove the `if a.opts.Kit == nil` guard.
**7b. Remove `AgentRunner` interface**:
`internal/app/options.go:17-28` — delete `AgentRunner`. The `Agent AgentRunner` field is no longer used when `Kit` is set. Remove the `Agent` field from `Options`.
**7c. Remove `Extensions` field from `app.Options`**:
`internal/app/options.go:94-98` — the app no longer calls `a.opts.Extensions.Emit()` directly. Extension dispatch goes through SDK hooks/events. Remove the field and all `a.opts.Extensions` references in `app.go`.
**7d. Simplify `BuildAppOptions()` in `cmd/setup.go`**:
Remove the `mcpAgent` and `extRunner` parameters since the app gets these from `Kit`:
```go
func BuildAppOptions(kitInstance *kit.Kit, mcpConfig *config.Config,
modelName string, serverNames, toolNames []string) app.Options {
return app.Options{
Kit: kitInstance,
MCPConfig: mcpConfig,
ModelName: modelName,
ServerNames: serverNames,
ToolNames: toolNames,
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}
}
```
**7e. Remove `updateUsage()` from `app.go`** (`app.go:596-627`):
Replaced by `updateUsageFromTurnResult()` which works with `TurnResult` instead of raw `GenerateWithLoopResult`.
**7f. Simplify `SessionStart` emission**:
Move SessionStart from `cmd/root.go:448` into `Kit.New()` or a new `Kit.EmitSessionStart()` method called by the CLI after extension context is configured.
**7g. Remove `inputSource()` helper** (`app.go:524-532`):
Only used by the now-removed Input extension emission.
**7h. Run final verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
Confirm no references to removed types/functions. Confirm no unused imports.
---
## Verification Checklist
- [ ] `go build -o output/kit ./cmd/kit` succeeds
- [ ] `go test -race ./...` passes
- [ ] `golangci-lint run ./...` — 0 issues
- [ ] `kit.New()` creates agent, session, extensions in one call
- [ ] `cmd/root.go` no longer calls `SetupAgent()` directly
- [ ] `executeStep()` delegates to `kit.PromptResult()`
- [ ] SDK events drive TUI rendering (tool calls, streaming, results)
- [ ] Extension observation events (AgentStart/End, MessageStart/Update/End) fire via SDK bridge
- [ ] Extension interception events (Input, BeforeAgentStart, ToolCall, ToolResult) still work
- [ ] Usage tracker receives correct token counts
- [ ] Session persistence works (tree session)
- [ ] `--continue` / `--no-session` / `--session` flags work
- [ ] Spinner shows/hides correctly
- [ ] Interactive mode (BubbleTea) works
- [ ] Non-interactive mode (`-p "..."`) works
- [ ] Extension SessionShutdown fires on close
- [ ] No remaining direct `extensions.Emit()` calls in `app.go`
- [ ] `AgentRunner` interface removed
- [ ] `app.Options.Extensions` field removed
+5 -1
View File
@@ -66,6 +66,7 @@ internal/app/ TUI/interactive mode — subscribes to SDK events
| **07** | P2 | Compaction APIs | 00, 03, 04 |
| **08** | P2 | Skills & prompts system | 00, 02 |
| **09** | P3 | Extension hook system | 00, 01, 02, 03 |
| **10** | P4 | App-as-SDK-consumer — complete integration | 0009 |
### Recommended Batches
@@ -81,6 +82,9 @@ Auth, compaction, skills. CLI commands use SDK functions.
**Batch 4 — Extensibility** (Plan 09):
Hook system with extension bridge. App's extension dispatch routes through SDK hooks.
**Batch 5 — Full Integration** (Plan 10):
CLI uses `kit.New()`, app calls `kit.PromptResult()`, extension events route through SDK EventBus. Closes all deferred items from Plans 03, 05, 09. Removes `AgentRunner` interface, `app.Options.Extensions`, and legacy `executeStep` code.
## Parity with Pi SDK
After all plans:
@@ -97,4 +101,4 @@ After all plans:
| Compaction APIs | Yes | Plan 07 |
| Skills/prompts system | Yes | Plan 08 |
| Extension hooks (20+ events) | Yes | Plan 09 |
| App built on SDK | Yes | Gradual across all plans |
| App built on SDK | Yes | Plan 10 (completes deferred work from 03, 05, 09) |