- 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
348 lines
11 KiB
Go
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
|
|
}
|