package clawdforge import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "sync/atomic" ) // ---------- v0.2: multi-turn Session API ------------------------------------ // // The Session surface is purely additive — v0.1 callers (Client.Run, // Client.UploadFile, etc.) keep their byte-identical behavior. The session // methods wrap the server's /sessions/* endpoints introduced in v0.2. // SessionOptions configures Client.NewSession. Both fields are optional; the // zero value yields agent="claude" with no metadata. type SessionOptions struct { // Agent is the ACPX agent to drive (default "claude" server-side when // blank). Mirrors the server's CreateSessionRequest.agent field. Agent string // Meta is an arbitrary JSON-serializable map persisted on the server's // session ledger. Useful for app-side correlation. Meta map[string]any } // TurnOption is the optional argument for Session.Turn. Multiple options // passed are merged left-to-right (last non-zero field wins per field). type TurnOption struct { // Files is a slice of file_token values previously returned by // Client.UploadFile / Client.UploadReader. Resolved server-side. Files []string // TimeoutMs is the per-turn timeout in milliseconds. Zero means use // the server's default. Note the server field is `timeout_secs`; the // SDK exposes ms here for symmetry with TurnResult.DurationMs and // converts on the wire (rounded up). TimeoutMs int } // TurnEvent is one structured event emitted during a turn — typed text, // thinking, or a tool-call record. Fields not present on a given event type // are zero-valued. Mirrors the server's event shape from acpx_runner. type TurnEvent struct { Type string `json:"type"` Content string `json:"content,omitempty"` Name string `json:"name,omitempty"` Args map[string]any `json:"args,omitempty"` Result any `json:"result,omitempty"` } // TurnResult is the parsed response from POST /sessions/{id}/turn on success. // // Use Text() to concatenate just the "text" events into a single string, // dropping thinking/tool_call frames. type TurnResult struct { Ok bool `json:"ok"` SessionID string `json:"session_id"` TurnIndex int `json:"turn_index"` Events []TurnEvent `json:"events"` StopReason string `json:"stop_reason"` DurationMs int `json:"duration_ms"` } // Text concatenates the Content of every event whose Type == "text", // in order. Non-text events (thinking, tool_call) are skipped. Use this // when you want the model's user-facing reply only. func (r *TurnResult) Text() string { if r == nil || len(r.Events) == 0 { return "" } var b strings.Builder for _, ev := range r.Events { if ev.Type == "text" { b.WriteString(ev.Content) } } return b.String() } // SessionState is the parsed response from GET /sessions/{id}. Mirrors the // server's session-state shape; LastTurnAt and ClosedAt are pointers because // they're nullable until the first turn / first close. type SessionState struct { SessionID string `json:"session_id"` Agent string `json:"agent"` AppName string `json:"app_name,omitempty"` CreatedAt int64 `json:"created_at"` LastTurnAt *int64 `json:"last_turn_at"` TurnCount int `json:"turn_count"` ClosedAt *int64 `json:"closed_at"` } // Session is a handle to a multi-turn session on the server. Construct via // Client.NewSession. Methods are safe for concurrent use; Turn calls on the // same session are serialized via an internal mutex so the server sees them // in order. // // Always Close the session when you're done — Close is idempotent (a second // call short-circuits without a network round-trip via an atomic flag). // // s, err := client.NewSession(ctx, nil) // if err != nil { ... } // defer s.Close(ctx) type Session struct { client *Client sessionID string agent string createdAt int64 // closed short-circuits the second Close call without hitting the // network. The server is itself idempotent, but this saves the round // trip and makes Close safe to call from any number of defers. closed atomic.Bool // turnMu serializes concurrent Turn calls on the same session so the // server observes them in caller-determined order. Per-session, NOT // global — different sessions on the same Client never block each // other. turnMu sync.Mutex } // ID returns the server-assigned session id. func (s *Session) ID() string { return s.sessionID } // Agent returns the agent name the session was created against // (default "claude"). func (s *Session) Agent() string { return s.agent } // CreatedAt returns the unix timestamp the server recorded at create time. func (s *Session) CreatedAt() int64 { return s.createdAt } // ---------- Client session methods ------------------------------------------ // createSessionResponse is the wire shape of POST /sessions on success. type createSessionResponse struct { OK bool `json:"ok"` SessionID string `json:"session_id"` Agent string `json:"agent"` CreatedAt int64 `json:"created_at"` Cwd string `json:"cwd,omitempty"` } // NewSession issues POST /sessions and returns a *Session handle for // follow-up Turn / Close / state calls. // // opts may be nil for the all-default case (agent="claude", no meta). // // Errors mirror the rest of the SDK: 401/403 → ErrAuth, transport failures // → *TransportError, other non-2xx → *APIError. func (c *Client) NewSession(ctx context.Context, opts *SessionOptions) (*Session, error) { body := struct { Agent string `json:"agent,omitempty"` Meta map[string]any `json:"meta,omitempty"` }{} if opts != nil { body.Agent = opts.Agent body.Meta = opts.Meta } buf, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("clawdforge: marshal SessionOptions: %w", err) } req, err := c.newRequest(ctx, http.MethodPost, "/sessions", bytes.NewReader(buf), "application/json") if err != nil { return nil, err } var out createSessionResponse if err := c.do(req, &out); err != nil { return nil, err } if out.SessionID == "" { return nil, errors.New("clawdforge: NewSession: server returned empty session_id") } return &Session{ client: c, sessionID: out.SessionID, agent: out.Agent, createdAt: out.CreatedAt, }, nil } // GetSession issues GET /sessions/{id} and returns the server's view of the // session — turn count, timestamps, closed state, etc. A 404 on a session // that exists under a different token (cross-token access) surfaces as // *APIError with StatusCode==404, matching the server's // no-existence-leak design. func (c *Client) GetSession(ctx context.Context, sessionID string) (*SessionState, error) { if sessionID == "" { return nil, errors.New("clawdforge: GetSession: sessionID is required") } req, err := c.newRequest(ctx, http.MethodGet, "/sessions/"+url.PathEscape(sessionID), nil, "") if err != nil { return nil, err } var out SessionState if err := c.do(req, &out); err != nil { return nil, err } return &out, nil } // listSessionsResponse is the wire shape of GET /sessions. type listSessionsResponse struct { OK bool `json:"ok"` Sessions []SessionState `json:"sessions"` Count int `json:"count"` } // ListSessions issues GET /sessions and returns every session visible to the // calling token (per-app isolation is enforced server-side). The result // includes closed-but-not-yet-hard-deleted sessions by default. func (c *Client) ListSessions(ctx context.Context) ([]SessionState, error) { req, err := c.newRequest(ctx, http.MethodGet, "/sessions", nil, "") if err != nil { return nil, err } var out listSessionsResponse if err := c.do(req, &out); err != nil { return nil, err } return out.Sessions, nil } // ---------- Session methods ------------------------------------------------- // turnRequestBody is the wire shape of POST /sessions/{id}/turn. type turnRequestBody struct { Prompt string `json:"prompt"` Files []string `json:"files,omitempty"` TimeoutSecs int `json:"timeout_secs,omitempty"` } // Turn sends a prompt to the session and returns the structured result — // the full event batch, stop reason, and timing. Multiple TurnOption values // are merged; later values override earlier ones per-field when non-zero. // // Concurrent Turn calls on the same Session are serialized by an internal // mutex so the server observes them in caller-determined order. Different // sessions on the same Client never block each other. func (s *Session) Turn(ctx context.Context, prompt string, opts ...TurnOption) (*TurnResult, error) { if s == nil { return nil, errors.New("clawdforge: Turn called on nil *Session") } if prompt == "" { return nil, errors.New("clawdforge: Turn: prompt is required") } if s.closed.Load() { return nil, errors.New("clawdforge: Turn called on closed session") } // Merge options left-to-right; non-zero fields override. body := turnRequestBody{Prompt: prompt} for _, o := range opts { if len(o.Files) > 0 { body.Files = o.Files } if o.TimeoutMs > 0 { // Server takes seconds; round UP so a sub-second SDK timeout // doesn't degrade to 0 (= "use default") on the wire. secs := o.TimeoutMs / 1000 if o.TimeoutMs%1000 != 0 { secs++ } body.TimeoutSecs = secs } } buf, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("clawdforge: marshal Turn body: %w", err) } // Serialize concurrent Turns on the same session. Held for the entire // HTTP round-trip so the server sees ordered prompt arrivals — the // real ordering constraint lives at the model/agent layer, but the // SDK can at least guarantee the request ordering it dispatches. s.turnMu.Lock() defer s.turnMu.Unlock() req, err := s.client.newRequest( ctx, http.MethodPost, "/sessions/"+url.PathEscape(s.sessionID)+"/turn", bytes.NewReader(buf), "application/json", ) if err != nil { return nil, err } var out TurnResult if err := s.client.do(req, &out); err != nil { return nil, err } return &out, nil } // closeResponse is the wire shape of DELETE /sessions/{id}. type closeResponse struct { OK bool `json:"ok"` AlreadyClosed bool `json:"already_closed,omitempty"` } // Close issues DELETE /sessions/{id} to soft-close the session server-side. // // Close is idempotent — the second and subsequent calls short-circuit via // an atomic flag without a network round-trip. The server's close endpoint // is itself idempotent (returns {ok:true, already_closed:true} on a second // hit), but the local short-circuit saves the request round-trip in the // common defer-Close pattern. // // Safe to call from defer / cleanup paths regardless of prior state. func (s *Session) Close(ctx context.Context) error { if s == nil { return nil } // CompareAndSwap so only one caller wins the network call when many // defers race. Subsequent callers see the flag already set and return // nil without contacting the server. if !s.closed.CompareAndSwap(false, true) { return nil } req, err := s.client.newRequest( ctx, http.MethodDelete, "/sessions/"+url.PathEscape(s.sessionID), nil, "", ) if err != nil { // Roll the flag back so the caller can retry — Close failing on // network setup shouldn't strand the session as "locally closed // but actually open server-side" with no way to retry. s.closed.Store(false) return err } var out closeResponse if err := s.client.do(req, &out); err != nil { s.closed.Store(false) return err } return nil }