feat: opencode improve sse

This commit is contained in:
Carlos Florêncio
2026-02-21 21:27:38 +00:00
parent 1556dfeda8
commit f0d84b32b8
4 changed files with 481 additions and 85 deletions
@@ -93,13 +93,13 @@ func (a *OpenCode) ListModels(ctx context.Context) (*ModelList, error) {
}
func (a *OpenCode) BuildCommand(opts CommandOptions) Command {
return Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build()
return Cmd("npx", "-y", "opencode-ai@1.1.59", "serve", "--hostname", "127.0.0.1", "--port", "0").Build()
}
func (a *OpenCode) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build(),
Cmd: Cmd("npx", "-y", "opencode-ai@1.1.59", "serve", "--hostname", "127.0.0.1", "--port", "0").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
@@ -86,6 +86,9 @@ type Adapter struct {
// Result completion signaling
resultCh chan resultComplete
// Model selection (parsed from AGENT_MODEL env var, format: "provider/model")
model *opencode.ModelSpec
// Token tracking
totalTokens int
modelContextWindow int
@@ -140,11 +143,19 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter {
if agentID == "" {
agentID = "opencode" // Fallback for unknown agents using this protocol
}
// Parse model from AGENT_MODEL env var (format: "provider/model")
var modelSpec *opencode.ModelSpec
if modelStr := os.Getenv("AGENT_MODEL"); modelStr != "" {
modelSpec = parseModelSpec(modelStr)
log.Info("parsed model from AGENT_MODEL", zap.String("model", modelStr))
}
return &Adapter{
cfg: cfg,
logger: log.WithFields(zap.String("adapter", "opencode"), zap.String("agent_id", agentID)),
agentID: agentID,
normalizer: NewNormalizer(),
model: modelSpec,
ctx: ctx,
cancel: cancel,
updatesCh: make(chan AgentEvent, 100),
@@ -181,16 +192,23 @@ func (a *Adapter) PrepareEnvironment() (map[string]string, error) {
"OPENCODE_SERVER_PASSWORD": a.password,
}
// Set up permissions based on config
if a.cfg.AutoApprove {
env["OPENCODE_PERMISSION"] = `{"question":"deny"}`
} else {
env["OPENCODE_PERMISSION"] = `{"edit":"ask","bash":"ask","webfetch":"ask","doom_loop":"ask","external_directory":"ask","question":"deny"}`
}
// Set up permissions based on config with per-permission rules
env["OPENCODE_PERMISSION"] = buildPermissionJSON(a.cfg.AutoApprove)
// Inject runtime config for auto-compaction
env["OPENCODE_CONFIG_CONTENT"] = `{"compaction":{"auto":true}}`
return env, nil
}
// buildPermissionJSON builds the OPENCODE_PERMISSION JSON value.
func buildPermissionJSON(autoApprove bool) string {
if autoApprove {
return `{"rules":[{"permission":"bash","grant":"always"},{"permission":"write","grant":"always"},{"permission":"read","grant":"always"},{"permission":"edit","grant":"always"},{"permission":"glob","grant":"always"},{"permission":"grep","grant":"always"},{"permission":"webfetch","grant":"always"}],"question":"deny"}`
}
return `{"rules":[{"permission":"bash","grant":"ask"},{"permission":"write","grant":"ask"},{"permission":"read","grant":"ask"},{"permission":"edit","grant":"ask"}],"question":"deny"}`
}
// PrepareCommandArgs returns extra command-line arguments for the agent process.
// For OpenCode, no extra args are needed - MCP is configured via config file.
func (a *Adapter) PrepareCommandArgs() []string {
@@ -248,6 +266,9 @@ func (a *Adapter) Initialize(ctx context.Context) error {
Version: "unknown",
}
// Fetch providers and commands in the background — don't block initialization
go a.fetchPostInitData()
a.logger.Info("OpenCode adapter initialized")
return nil
@@ -325,12 +346,13 @@ func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string,
a.logger.Info("created new session", zap.String("session_id", sessionID))
// Emit session status event for resume token storage
// Emit session status event
a.sendUpdate(AgentEvent{
Type: streams.EventTypeSessionStatus,
SessionID: sessionID,
Type: streams.EventTypeSessionStatus,
SessionID: sessionID,
SessionStatus: streams.SessionStatusNew,
Data: map[string]any{
"session_status": "active",
"session_status": streams.SessionStatusNew,
"init": true,
},
})
@@ -357,6 +379,17 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error {
zap.String("original_session_id", sessionID),
zap.String("new_session_id", newSessionID))
// Emit session status event for resumed session
a.sendUpdate(AgentEvent{
Type: streams.EventTypeSessionStatus,
SessionID: newSessionID,
SessionStatus: streams.SessionStatusResumed,
Data: map[string]any{
"session_status": streams.SessionStatusResumed,
"init": true,
},
})
return nil
}
@@ -397,15 +430,17 @@ func (a *Adapter) Prompt(ctx context.Context, message string, _ []v1.MessageAtta
a.logger.Warn("failed to start event stream", zap.Error(err))
}
// Send prompt
// TODO: Support model selection from config
if err := client.SendPrompt(ctx, sessionID, message, nil, "", ""); err != nil {
// Send prompt with model selection (blocks until OpenCode finishes)
if err := client.SendPrompt(ctx, sessionID, message, a.model, "", ""); err != nil {
a.mu.Lock()
a.resultCh = nil
a.mu.Unlock()
return fmt.Errorf("failed to send prompt: %w", err)
}
a.logger.Info("prompt HTTP request completed, waiting for SSE idle signal",
zap.String("session_id", sessionID))
// Wait for result via control channel or context cancellation
controlCh := client.ControlChannel()
@@ -617,6 +652,17 @@ func (a *Adapter) handleSDKEvent(event *opencode.SDKEventEnvelope) {
case opencode.SDKEventPermissionAsked:
a.handlePermissionAsked(event.Properties, sessionID)
case opencode.SDKEventSessionCompacted:
a.handleSessionCompacted(event.Properties, sessionID)
case opencode.SDKEventTodoUpdated:
// Log but don't emit — todo tracking is informational
a.logger.Debug("todo.updated event received")
case opencode.SDKEventMessageRemoved, opencode.SDKEventMessagePartRemoved:
// Log message/part removal — these occur during compaction
a.logger.Debug("message removal event", zap.String("event_type", event.Type))
case opencode.SDKEventSessionError:
props, err := opencode.ParseSessionError(event.Properties)
if err != nil {
@@ -872,6 +918,27 @@ func deriveToolTitle(toolName string, input map[string]any) string {
return ""
}
// handleSessionCompacted processes session.compacted events from auto-compaction.
func (a *Adapter) handleSessionCompacted(props json.RawMessage, sessionID string) {
a.logger.Info("session compacted", zap.String("session_id", sessionID))
// Reset token tracking since compaction changes the context window usage
a.mu.Lock()
a.totalTokens = 0
// Clear text part state since message IDs may have changed
a.textParts = make(map[string]*textPartState)
a.messageRoles = make(map[string]string)
a.mu.Unlock()
// Emit a context window update showing reset usage
a.sendUpdate(AgentEvent{
Type: streams.EventTypeContextWindow,
SessionID: sessionID,
ContextWindowUsed: 0,
ContextWindowSize: int64(a.modelContextWindow),
})
}
// handleMessageUpdated processes message.updated events (for token tracking)
func (a *Adapter) handleMessageUpdated(props json.RawMessage, sessionID string) {
parsed, err := opencode.ParseMessageUpdated(props)
@@ -996,6 +1063,20 @@ func (a *Adapter) handlePermissionAsked(props json.RawMessage, sessionID string)
}
}
// parseModelSpec parses a "providerID/modelID" string into a ModelSpec.
// If no "/" is found, the whole string is used as modelID with empty providerID.
func parseModelSpec(model string) *opencode.ModelSpec {
if idx := strings.Index(model, "/"); idx > 0 {
return &opencode.ModelSpec{
ProviderID: model[:idx],
ModelID: model[idx+1:],
}
}
return &opencode.ModelSpec{
ModelID: model,
}
}
// formatPermissionTitle builds a display title for a permission request.
func formatPermissionTitle(permission string, metadata map[string]any) string {
if metadata != nil {
@@ -1028,6 +1109,87 @@ func (a *Adapter) RespondToPermission(ctx context.Context, requestID string, app
return a.client.ReplyPermission(ctx, requestID, reply, msg)
}
// fetchPostInitData fetches providers and commands after initialization.
// Runs in a goroutine with a timeout so it never blocks the main flow.
func (a *Adapter) fetchPostInitData() {
ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
defer cancel()
a.fetchContextWindow(ctx)
a.discoverCommands(ctx)
}
// fetchContextWindow queries the /provider endpoint and updates the model context window.
func (a *Adapter) fetchContextWindow(ctx context.Context) {
providers, err := a.client.ListProviders(ctx)
if err != nil {
a.logger.Debug("failed to fetch providers for context window", zap.Error(err))
return
}
// Look up the context window for our configured model
a.mu.RLock()
model := a.model
a.mu.RUnlock()
if model == nil {
return
}
for _, p := range providers.All {
if p.ID != model.ProviderID {
continue
}
if modelInfo, ok := p.Models[model.ModelID]; ok && modelInfo.Limit.Context > 0 {
a.mu.Lock()
a.modelContextWindow = modelInfo.Limit.Context
a.mu.Unlock()
a.logger.Info("updated context window from provider",
zap.String("provider", model.ProviderID),
zap.String("model", model.ModelID),
zap.Int("context_window", modelInfo.Limit.Context))
return
}
}
}
// discoverCommands queries the /command endpoint and emits available commands.
func (a *Adapter) discoverCommands(ctx context.Context) {
commands, err := a.client.ListCommands(ctx)
if err != nil {
a.logger.Debug("failed to discover commands", zap.Error(err))
return
}
if len(commands) == 0 {
return
}
a.mu.RLock()
sessionID := a.sessionID
a.mu.RUnlock()
available := make([]streams.AvailableCommand, 0, len(commands))
for _, cmd := range commands {
ac := streams.AvailableCommand{
Name: cmd.Name,
Description: cmd.Description,
}
if cmd.Input != nil {
ac.InputHint = cmd.Input.Hint
}
available = append(available, ac)
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypeAvailableCommands,
SessionID: sessionID,
AvailableCommands: available,
})
a.logger.Info("discovered slash commands", zap.Int("count", len(available)))
}
// openCodeConfigPath returns the path to the OpenCode config file.
func openCodeConfigPath(homeDir string) (string, string, error) {
if homeDir == "" {
+268 -70
View File
@@ -18,6 +18,13 @@ import (
"github.com/kandev/kandev/internal/common/logger"
)
// SSE reconnection constants
const (
sseReconnectBaseDelay = 3 * time.Second
sseReconnectMaxDelay = 30 * time.Second
sseMaxRetries = 20
)
// Client manages HTTP communication with OpenCode server
type Client struct {
baseURL string
@@ -31,8 +38,9 @@ type Client struct {
controlCh chan ControlEvent
// SSE connection tracking - prevents multiple concurrent connections
sseCancel context.CancelFunc
sseActive bool
sseCancel context.CancelFunc
sseActive bool
lastEventID string // Last-Event-ID for reconnection
mu sync.RWMutex
closed bool
@@ -245,7 +253,8 @@ func (c *Client) ForkSession(ctx context.Context, sessionID string) (string, err
return session.ID, nil
}
// SendPrompt sends a prompt to the session
// SendPrompt sends a prompt to the session.
// This call blocks until OpenCode finishes processing the prompt.
func (c *Client) SendPrompt(ctx context.Context, sessionID, prompt string, model *ModelSpec, agent, variant string) error {
req := PromptRequest{
Model: model,
@@ -261,25 +270,30 @@ func (c *Client) SendPrompt(ctx context.Context, sessionID, prompt string, model
return fmt.Errorf("marshal prompt request: %w", err)
}
c.logger.Debug("sending prompt HTTP request",
zap.String("session_id", sessionID),
zap.Int("body_len", len(body)))
path := fmt.Sprintf("/session/%s/message", sessionID)
// Use a dedicated client with long timeout for prompts - they can take minutes
resp, err := c.doPromptRequest(ctx, http.MethodPost, path, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("send prompt request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Read and validate response
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read prompt response: %w", err)
}
c.logger.Debug("prompt HTTP response received",
zap.Int("status", resp.StatusCode),
zap.Int("body_len", len(respBody)))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("prompt failed: HTTP %d: %s", resp.StatusCode, string(respBody))
}
// Validate response structure
trimmed := strings.TrimSpace(string(respBody))
if trimmed == "" {
return fmt.Errorf("prompt returned empty response")
@@ -290,14 +304,12 @@ func (c *Client) SendPrompt(ctx context.Context, sessionID, prompt string, model
return fmt.Errorf("parse prompt response: %w", err)
}
// Check for success response: { info, parts }
if _, hasInfo := parsed["info"]; hasInfo {
if _, hasParts := parsed["parts"]; hasParts {
return nil
}
}
// Check for error response: { name, data }
if name, ok := parsed["name"].(string); ok {
message := "unknown error"
if data, ok := parsed["data"].(map[string]any); ok {
@@ -359,12 +371,101 @@ func (c *Client) ReplyPermission(ctx context.Context, requestID, reply string, m
return nil
}
// Summarize triggers compaction/summarization for a session.
func (c *Client) Summarize(ctx context.Context, sessionID string, model *ModelSpec) error {
payload := map[string]any{}
if model != nil {
payload["model"] = model
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal summarize request: %w", err)
}
path := fmt.Sprintf("/session/%s/summarize", sessionID)
resp, err := c.doPromptRequest(ctx, http.MethodPost, path, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("summarize request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("summarize failed: HTTP %d: %s", resp.StatusCode, string(respBody))
}
_, _ = io.ReadAll(resp.Body)
return nil
}
// ListProviders fetches provider information including model context windows.
func (c *Client) ListProviders(ctx context.Context) (*ProviderListResponse, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/provider", nil)
if err != nil {
return nil, fmt.Errorf("list providers request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list providers failed: HTTP %d: %s", resp.StatusCode, string(body))
}
var result ProviderListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parse providers response: %w", err)
}
return &result, nil
}
// ListCommands fetches available slash commands from the server.
func (c *Client) ListCommands(ctx context.Context) ([]CommandInfo, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/command", nil)
if err != nil {
return nil, fmt.Errorf("list commands request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list commands failed: HTTP %d: %s", resp.StatusCode, string(body))
}
var result []CommandInfo
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parse commands response: %w", err)
}
return result, nil
}
// RunSessionCommand executes a slash command in a session.
func (c *Client) RunSessionCommand(ctx context.Context, sessionID, command string) error {
payload := map[string]string{"command": command}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal command request: %w", err)
}
path := fmt.Sprintf("/session/%s/command", sessionID)
resp, err := c.doPromptRequest(ctx, http.MethodPost, path, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("run command request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("run command failed: HTTP %d: %s", resp.StatusCode, string(respBody))
}
_, _ = io.ReadAll(resp.Body)
return nil
}
// StartEventStream starts the SSE event stream and processes events.
// It ensures only one SSE connection is active at a time to prevent duplicate events.
// Supports automatic reconnection with Last-Event-ID for event resumption.
func (c *Client) StartEventStream(ctx context.Context, sessionID string) error {
c.mu.Lock()
// Check if already connected - prevent multiple concurrent SSE connections
// which would cause duplicate event processing
if c.sseActive {
c.mu.Unlock()
c.logger.Debug("SSE stream already active, skipping duplicate connection",
@@ -374,127 +475,173 @@ func (c *Client) StartEventStream(ctx context.Context, sessionID string) error {
c.sseActive = true
c.mu.Unlock()
url := c.baseURL + "/event?directory=" + c.directory
// Create a cancellable context for this SSE connection
sseCtx, sseCancel := context.WithCancel(ctx)
// Store the cancel function so we can close this connection later if needed
c.mu.Lock()
c.sseCancel = sseCancel
c.mu.Unlock()
req, err := http.NewRequestWithContext(sseCtx, http.MethodGet, url, nil)
if err != nil {
// Connect and start processing with reconnect support
if err := c.connectSSE(sseCtx, sessionID); err != nil {
c.mu.Lock()
c.sseActive = false
c.sseCancel = nil
c.mu.Unlock()
sseCancel()
return fmt.Errorf("create event stream request: %w", err)
return err
}
return nil
}
// connectSSE establishes the SSE connection and starts the event loop.
func (c *Client) connectSSE(ctx context.Context, sessionID string) error {
resp, err := c.dialSSE(ctx)
if err != nil {
return err
}
c.logger.Debug("SSE stream connected", zap.String("session_id", sessionID))
go c.sseEventLoop(ctx, sessionID, resp.Body)
return nil
}
// dialSSE creates and executes an SSE HTTP request, including Last-Event-ID if available.
func (c *Client) dialSSE(ctx context.Context) (*http.Response, error) {
url := c.baseURL + "/event?directory=" + c.directory
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create event stream request: %w", err)
}
req.Header.Set("Authorization", c.buildAuthHeader())
req.Header.Set("X-OpenCode-Directory", c.directory)
req.Header.Set("Accept", "text/event-stream")
// Use a client without timeout for SSE
c.mu.RLock()
lastID := c.lastEventID
c.mu.RUnlock()
if lastID != "" {
req.Header.Set("Last-Event-ID", lastID)
}
sseClient := &http.Client{}
resp, err := sseClient.Do(req)
if err != nil {
c.mu.Lock()
c.sseActive = false
c.sseCancel = nil
c.mu.Unlock()
sseCancel()
return fmt.Errorf("connect event stream: %w", err)
return nil, fmt.Errorf("connect event stream: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
c.mu.Lock()
c.sseActive = false
c.sseCancel = nil
c.mu.Unlock()
sseCancel()
return fmt.Errorf("event stream failed: HTTP %d: %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("event stream failed: HTTP %d: %s", resp.StatusCode, string(body))
}
c.logger.Debug("SSE stream connected",
zap.String("session_id", sessionID))
// Process events in background
go c.processEventStream(sseCtx, sessionID, resp.Body)
return nil
return resp, nil
}
// processEventStream reads and processes SSE events
func (c *Client) processEventStream(ctx context.Context, sessionID string, body io.ReadCloser) {
// sseEventLoop reads SSE events, dispatches them, and reconnects on disconnect.
func (c *Client) sseEventLoop(ctx context.Context, sessionID string, body io.ReadCloser) {
defer func() {
_ = body.Close()
// Mark SSE as inactive when stream ends
c.mu.Lock()
c.sseActive = false
c.sseCancel = nil
c.mu.Unlock()
c.logger.Debug("SSE stream ended", zap.String("session_id", sessionID))
}()
for attempt := 0; ; attempt++ {
disconnectReason := c.readSSEStream(ctx, sessionID, body)
_ = body.Close()
if disconnectReason == sseDisconnectCancelled || disconnectReason == sseDisconnectClosed {
return
}
// Attempt reconnection with exponential backoff
if attempt >= sseMaxRetries {
c.logger.Warn("SSE max retries reached, giving up",
zap.Int("retries", attempt))
c.sendControlEvent("disconnected")
return
}
delay := sseBackoff(attempt)
c.logger.Info("SSE stream disconnected, reconnecting",
zap.Int("attempt", attempt+1),
zap.Duration("delay", delay))
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
resp, err := c.dialSSE(ctx)
if err != nil {
c.logger.Warn("SSE reconnect failed", zap.Error(err), zap.Int("attempt", attempt+1))
continue
}
body = resp.Body
c.logger.Info("SSE stream reconnected", zap.Int("attempt", attempt+1))
}
}
// sseDisconnectReason indicates why the SSE stream ended.
type sseDisconnectReason int
const (
sseDisconnectError sseDisconnectReason = iota // network/scan error, should reconnect
sseDisconnectCancelled // context cancelled, no reconnect
sseDisconnectClosed // client closed, no reconnect
)
// readSSEStream reads events from a single SSE connection until it ends.
func (c *Client) readSSEStream(ctx context.Context, sessionID string, body io.Reader) sseDisconnectReason {
scanner := bufio.NewScanner(body)
// Increase buffer size for potentially large events
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
var dataBuffer strings.Builder
var currentID string
for scanner.Scan() {
select {
case <-ctx.Done():
return
return sseDisconnectCancelled
default:
}
line := scanner.Text()
// SSE format: "data: {...}"
// Track event ID for reconnection
if strings.HasPrefix(line, "id: ") {
currentID = strings.TrimPrefix(line, "id: ")
continue
}
if strings.HasPrefix(line, "data: ") {
dataBuffer.WriteString(strings.TrimPrefix(line, "data: "))
continue
}
// Empty line signals end of event
if line == "" && dataBuffer.Len() > 0 {
data := strings.TrimSpace(dataBuffer.String())
dataBuffer.Reset()
// Update Last-Event-ID
if currentID != "" {
c.mu.Lock()
c.lastEventID = currentID
c.mu.Unlock()
currentID = ""
}
if data == "" {
continue
}
event, err := ParseSDKEvent([]byte(data))
if err != nil {
c.logger.Warn("failed to parse SDK event", zap.Error(err))
continue
}
// Filter events for this session
if !c.eventMatchesSession(event, sessionID) {
continue
}
// Process control events
c.processControlEvent(event, sessionID)
// Call event handler
c.mu.RLock()
handler := c.eventHandler
c.mu.RUnlock()
if handler != nil {
handler(event)
}
c.dispatchSSEEvent(data, sessionID)
}
}
@@ -502,18 +649,69 @@ func (c *Client) processEventStream(ctx context.Context, sessionID string, body
c.logger.Error("event stream error", zap.Error(err))
}
// Notify disconnection (check if closed first to avoid panic)
c.mu.RLock()
closed := c.closed
c.mu.RUnlock()
if closed {
return sseDisconnectClosed
}
return sseDisconnectError
}
// dispatchSSEEvent parses and dispatches a single SSE event.
func (c *Client) dispatchSSEEvent(data, sessionID string) {
event, err := ParseSDKEvent([]byte(data))
if err != nil {
c.logger.Warn("failed to parse SDK event", zap.Error(err))
return
}
c.logger.Debug("SSE event received",
zap.String("type", event.Type),
zap.String("session_id", sessionID))
if !c.eventMatchesSession(event, sessionID) {
c.logger.Debug("SSE event filtered (session mismatch)", zap.String("type", event.Type))
return
}
c.processControlEvent(event, sessionID)
c.mu.RLock()
handler := c.eventHandler
c.mu.RUnlock()
if handler != nil {
handler(event)
}
}
// sendControlEvent sends a control event if the client is not closed.
func (c *Client) sendControlEvent(eventType string) {
c.mu.RLock()
closed := c.closed
c.mu.RUnlock()
if !closed {
select {
case c.controlCh <- ControlEvent{Type: "disconnected"}:
case c.controlCh <- ControlEvent{Type: eventType}:
default:
}
}
}
// sseBackoff computes exponential backoff delay for SSE reconnection.
func sseBackoff(attempt int) time.Duration {
delay := sseReconnectBaseDelay
for i := 0; i < attempt; i++ {
delay *= 2
if delay > sseReconnectMaxDelay {
delay = sseReconnectMaxDelay
break
}
}
return delay
}
// eventMatchesSession checks if an event belongs to the specified session
func (c *Client) eventMatchesSession(event *SDKEventEnvelope, sessionID string) bool {
// Try to extract sessionID from properties
+36
View File
@@ -253,6 +253,19 @@ type SDKTodo struct {
Priority string `json:"priority"`
}
// CommandInfo represents a slash command from GET /command.
type CommandInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
// Some commands have structured input
Input *CommandInputInfo `json:"input,omitempty"`
}
// CommandInputInfo describes command input expectations.
type CommandInputInfo struct {
Hint string `json:"hint,omitempty"`
}
// ProviderListResponse from GET /provider
type ProviderListResponse struct {
All []ProviderInfo `json:"all"`
@@ -274,6 +287,29 @@ type ProviderModelLimit struct {
Context int `json:"context"`
}
// SessionCompactedProperties for session.compacted events
type SessionCompactedProperties struct {
SessionID string `json:"sessionID"`
}
// ParseSessionCompacted parses session.compacted properties
func ParseSessionCompacted(data json.RawMessage) (*SessionCompactedProperties, error) {
var props SessionCompactedProperties
if err := json.Unmarshal(data, &props); err != nil {
return nil, err
}
return &props, nil
}
// ParseTodoUpdated parses todo.updated properties
func ParseTodoUpdated(data json.RawMessage) (*TodoUpdatedProperties, error) {
var props TodoUpdatedProperties
if err := json.Unmarshal(data, &props); err != nil {
return nil, err
}
return &props, nil
}
// ParseSDKEvent parses an SDK event from JSON
func ParseSDKEvent(data []byte) (*SDKEventEnvelope, error) {
var env SDKEventEnvelope