Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,17 @@ func (f *fakeSessionService) List(context.Context, sessionsvc.ListFilter) ([]dom
return nil, nil
}

func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) {
f.spawned = cfg
return domain.Session{
SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1")},
Status: domain.StatusIdle,
}, nil
}, len(cfg.Prompt), 0, nil
}

func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, _ bool) (domain.Session, error) {
return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
s, _, _, err := f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
return s, err
}

func (f *fakeSessionService) Get(context.Context, domain.SessionID) (domain.Session, error) {
Expand Down
8 changes: 7 additions & 1 deletion backend/internal/cli/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ type spawnResult struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"session"`
PromptBytes int `json:"promptBytes,omitempty"`
SystemPromptBytes int `json:"systemPromptBytes,omitempty"`
}

type agentProbeResult struct {
Expand Down Expand Up @@ -147,7 +149,11 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
if claimed != "" {
claimLabel = fmt.Sprintf(" (claimed %s)", claimed)
}
_, err = fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel)
promptSize := ""
if res.PromptBytes > 0 || res.SystemPromptBytes > 0 {
promptSize = fmt.Sprintf(" [prompt %d B, system %d B]", res.PromptBytes, res.SystemPromptBytes)
}
_, err = fmt.Fprintf(out, "spawned session %s (%s)%s%s\n", res.Session.ID, res.Session.Status, claimLabel, promptSize)
return err
},
}
Expand Down
5 changes: 4 additions & 1 deletion backend/internal/cli/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`)
_, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"},"promptBytes":0,"systemPromptBytes":123}`)
default:
http.NotFound(w, r)
}
Expand All @@ -207,6 +207,9 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
if !strings.Contains(out, "spawned session demo-11") {
t.Fatalf("output missing spawn: %s", out)
}
if !strings.Contains(out, "[prompt 0 B, system 123 B]") {
t.Fatalf("output missing system-only prompt metrics: %s", out)
}
if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "worker" {
t.Fatalf("spawn request = %#v", req)
}
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/daemon/wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ func TestWiring_StartSessionSpawnsScratchWithoutGitRepo(t *testing.T) {
t.Fatalf("startSession: %v", err)
}

session, err := svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker, Prompt: "try scratch"})
session, _, _, err := svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker, Prompt: "try scratch"})
if err != nil {
t.Fatalf("Spawn scratch: %v", err)
}
Expand Down Expand Up @@ -306,7 +306,7 @@ func TestStartSession_SpawnDoesNotPanicWhenNoTrackerToken(t *testing.T) {
// Spawn reaches withIssueContext (and the tracker guard) before the manager
// tries to materialize a workspace. The manager may return an error from the
// no-op runtime, but it must not panic — that is the regression.
_, _ = svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "107"})
_, _, _, _ = svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "107"})
}

func TestWiring_SeedScratchProjectOnBootUsesDataDir(t *testing.T) {
Expand Down
15 changes: 14 additions & 1 deletion backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
$ref: '#/components/schemas/SpawnSessionResponse'
description: Created
"400":
content:
Expand Down Expand Up @@ -3441,6 +3441,19 @@ components:
required:
- projectId
type: object
SpawnSessionResponse:
properties:
promptBytes:
type: integer
session:
$ref: '#/components/schemas/ControllersSessionView'
systemPromptBytes:
type: integer
required:
- session
- promptBytes
- systemPromptBytes
type: object
SubmitReviewInput:
properties:
body:
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/httpd/apispec/specgen/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ var schemaNames = map[string]string{
"ControllersCleanupSessionsQuery": "CleanupSessionsQuery",
"ControllersListSessionsResponse": "ListSessionsResponse",
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
"ControllersSpawnSessionResponse": "SpawnSessionResponse",
"ControllersSessionResponse": "SessionResponse",
"ControllersSessionPreviewResponse": "SessionPreviewResponse",
"ControllersSetSessionPreviewRequest": "SetSessionPreviewRequest",
Expand Down Expand Up @@ -746,7 +747,7 @@ func sessionOperations() []operation {
summary: "Spawn a new agent session",
reqBody: controllers.SpawnSessionRequest{},
resps: []respUnit{
{http.StatusCreated, controllers.SessionResponse{}},
{http.StatusCreated, controllers.SpawnSessionResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
Expand Down
11 changes: 10 additions & 1 deletion backend/internal/httpd/controllers/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,20 @@ type SpawnAttachmentInput struct {
Data string `json:"data"`
}

// SessionResponse is the { session } body shared by session create/get.
// SessionResponse is the { session } body shared by session reads and updates.
type SessionResponse struct {
Session SessionView `json:"session"`
}

// SpawnSessionResponse includes ephemeral measurements of the final assembled
// prompt texts. The fields are required so a measured zero remains distinct
// from a response that never measured prompt sizes.
type SpawnSessionResponse struct {
Session SessionView `json:"session"`
PromptBytes int `json:"promptBytes"`
SystemPromptBytes int `json:"systemPromptBytes"`
}

// ListWorkspaceFilesResponse is the body of GET /api/v1/sessions/{sessionId}/workspace/files.
type ListWorkspaceFilesResponse struct {
SessionID domain.SessionID `json:"sessionId"`
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/httpd/controllers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ var errPreviewFileNotFound = errors.New("preview file not found")
// SessionService is the controller-facing session service contract.
type SessionService interface {
List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error)
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error)
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error)
SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error)
Get(ctx context.Context, id domain.SessionID) (domain.Session, error)
Restore(ctx context.Context, id domain.SessionID) (sessionsvc.RestoreOutcome, error)
Expand Down Expand Up @@ -177,12 +177,12 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", attachErr.code, attachErr.message, nil)
return
}
sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName, Attachments: attachments})
sess, promptBytes, systemPromptBytes, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName, Attachments: attachments})
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusCreated, SessionResponse{Session: sessionView(sess)})
envelope.WriteJSON(w, http.StatusCreated, SpawnSessionResponse{Session: sessionView(sess), PromptBytes: promptBytes, SystemPromptBytes: systemPromptBytes})
}

// spawnAttachmentError carries a client-facing API error code + message for a
Expand Down
19 changes: 14 additions & 5 deletions backend/internal/httpd/controllers/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilte
return out, nil
}

func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) {
if f.spawnErr != nil {
return domain.Session{}, f.spawnErr
return domain.Session{}, 0, 0, f.spawnErr
}
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
f.sessions[s.ID] = s
return s, nil
return s, len(cfg.Prompt), 0, nil
}

func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) {
Expand All @@ -83,7 +83,8 @@ func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID do
}
}
}
return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
s, _, _, err := f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
return s, err
}

func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) {
Expand Down Expand Up @@ -332,7 +333,9 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
t.Fatalf("POST session = %d, want 201; body=%s", status, body)
}
var spawned struct {
Session sessionBody `json:"session"`
Session sessionBody `json:"session"`
PromptBytes *int `json:"promptBytes"`
SystemPromptBytes *int `json:"systemPromptBytes"`
}
mustJSON(t, body, &spawned)
if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" {
Expand All @@ -341,6 +344,12 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
if spawned.Session.DisplayName != "my worker" {
t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker")
}
if spawned.PromptBytes == nil || *spawned.PromptBytes != len("fix") {
t.Fatalf("spawned promptBytes = %v, want %d", spawned.PromptBytes, len("fix"))
}
if spawned.SystemPromptBytes == nil || *spawned.SystemPromptBytes != 0 {
t.Fatalf("spawned systemPromptBytes = %v, want present zero", spawned.SystemPromptBytes)
}

body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "")
if status != http.StatusOK {
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/integration/lifecycle_sqlite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func newStack(t *testing.T) *stack {
func TestSpawnPRKillRoundTrip(t *testing.T) {
ctx := context.Background()
st := newStack(t)
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "do it"})
sess, _, _, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -191,7 +191,7 @@ func TestSpawnPRKillRoundTrip(t *testing.T) {
func TestRestoreRoundTripPreservesMetadata(t *testing.T) {
ctx := context.Background()
st := newStack(t)
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "prompt"})
sess, _, _, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "prompt"})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -315,7 +315,7 @@ func TestCDCPollerReceivesSessionAndPREvents(t *testing.T) {
var got []cdc.Event
b.Subscribe(func(e cdc.Event) { got = append(got, e) })
poller := cdc.NewPoller(st.store, b, cdc.PollerConfig{})
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
sess, _, _, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
if err != nil {
t.Fatal(err)
}
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/observe/trackerintake/observer.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type Store interface {

// Spawner is the session creation surface used by intake.
type Spawner interface {
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error)
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error)
}

// TrackerResolver picks the tracker adapter for a project's configured
Expand Down Expand Up @@ -203,7 +203,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord
if issueID == "" || seen[issueID] {
continue
}
if _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{
if _, _, _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{
ProjectID: domain.ProjectID(project.ID),
IssueID: issueID,
Kind: domain.KindWorker,
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/observe/trackerintake/observer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,12 @@ type fakeSpawner struct {
failIssue domain.IssueID
}

func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) {
f.calls = append(f.calls, cfg)
if cfg.IssueID == f.failIssue {
return domain.Session{}, errors.New("spawn failed")
return domain.Session{}, 0, 0, errors.New("spawn failed")
}
return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, nil
return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, len(cfg.Prompt), 0, nil
}

func discardLogger() *slog.Logger {
Expand Down
23 changes: 14 additions & 9 deletions backend/internal/service/session/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type ListFilter struct {
// commander is the command-side surface Service delegates to: the
// *sessionmanager.Manager in production, a fake in tests.
type commander interface {
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error)
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, int, int, error)
RestoreWithMode(ctx context.Context, id domain.SessionID) (sessionmanager.RestoreResult, error)
Kill(ctx context.Context, id domain.SessionID) (bool, error)
RetireForReplacement(ctx context.Context, id domain.SessionID) error
Expand Down Expand Up @@ -155,28 +155,33 @@ func NewWithDeps(d Deps) *Service {
return s
}

// Spawn creates a session and returns the API-facing read model.
func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
// Spawn creates a session and returns the API-facing read model plus
// ephemeral prompt size measurements.
func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) {
project, err := s.requireProject(ctx, cfg.ProjectID)
if err != nil {
return domain.Session{}, err
return domain.Session{}, 0, 0, err
}
start := s.now()
firstSession, err := s.isFirstSession(ctx)
if err != nil {
return domain.Session{}, fmt.Errorf("count sessions: %w", err)
return domain.Session{}, 0, 0, fmt.Errorf("count sessions: %w", err)
}
cfg = s.withIssueContext(ctx, cfg, project)
rec, err := s.manager.Spawn(ctx, cfg)
rec, promptBytes, systemPromptBytes, err := s.manager.Spawn(ctx, cfg)
if err != nil {
s.emitSpawnFailed(cfg, err, s.now().Sub(start).Milliseconds())
return domain.Session{}, toAPIError(err)
return domain.Session{}, 0, 0, toAPIError(err)
}
s.emitSpawned(rec, s.now().Sub(start).Milliseconds())
if firstSession {
s.emitFirstSessionSpawned(rec, project)
}
return s.toSession(ctx, rec)
sess, err := s.toSession(ctx, rec)
if err != nil {
return domain.Session{}, 0, 0, err
}
return sess, promptBytes, systemPromptBytes, nil
}

// requireProject verifies the project is registered before any spawn write
Expand Down Expand Up @@ -319,7 +324,7 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec
return newestSession(existing), nil
}
}
sess, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
sess, _, _, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
if err != nil {
return domain.Session{}, err
}
Expand Down
Loading
Loading