clawdforge/clients/go/session.go
Kayos 41a522a469 clients/go: v0.2 multi-turn Session API
- Session struct with idempotent Close(ctx) (atomic.Bool short-circuit)
- Client.NewSession(ctx, opts) / ListSessions(ctx) / GetSession(ctx, id)
- TurnResult.Text() helper concatenates text events
- Per-session sync.Mutex serializes concurrent Turn calls
- clawdforge_session_test.go: 9 tests
- README "Multi-turn / Sessions (v0.2)" section

v0.1 Run path unchanged.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 06:34:12 -07:00

348 lines
11 KiB
Go

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
}