From bf9123ce4674c51c89733ffd04aade5d98776323 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:18:09 +0000 Subject: [PATCH 01/11] Serve child Turns only through the Subagent routes Session Turn list and retrieve now read root Turns through a new root-only query; the public_execution_turns view stays in the schema unchanged. A Subagent Turn ID on the Session routes, including as a list cursor, returns the same not found error as a missing Turn (SAT-07). Child Turns no longer record agent.session.turn.* lifecycle events on the Session event log (SAT-09). The Session log has no internal reader other than the public GET and creation streams, and creation-stream settlement reads root Turns only, so no internal signal is needed. Child Turns on the Subagent routes carry the Session's Agent ID as agent_id and keep subagent_id (SAT-08). Nested children follow the same rule. --- services/agents-api/internal/api/turns.go | 8 ++-- .../internal/db/queries/turn_reads.sql | 6 ++- .../internal/db/sqlc/turn_reads.sql.go | 19 +++++---- .../internal/store/runtime_idle_clock_test.go | 7 +++- .../internal/store/subagent_reads.go | 29 ++++++++++++-- .../internal/store/subagent_resources_test.go | 40 +++++++++++++++++-- .../store/subagent_turn_projection.go | 21 ++-------- .../agents-api/internal/store/turn_reads.go | 15 +++---- services/agents-api/internal/store/turns.go | 24 ++++------- 9 files changed, 101 insertions(+), 68 deletions(-) diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go index d8259da90..f56d8686d 100644 --- a/services/agents-api/internal/api/turns.go +++ b/services/agents-api/internal/api/turns.go @@ -12,6 +12,7 @@ import ( ) // @Summary Retrieve an execution Turn +// @Description Returns a root Turn of this Session. A Subagent Turn ID returns the same not found error as a missing Turn; read it through the Subagent Turn routes. // @Tags Turns // @Produce json // @Security BearerAuth @@ -42,7 +43,7 @@ func (h *Handler) getTurn(w http.ResponseWriter, r *http.Request) { } // @Summary List execution Turns -// @Description Returns persisted state in creation order. The cursor belongs to the same Session and tenant. Usage contains the latest recorded complete token breakdown; missing measurements remain null. +// @Description Returns the Session's root Turns in creation order; Subagent Turns are listed through the Subagent Turn routes. The cursor belongs to the same Session and tenant. Usage contains the latest recorded complete token breakdown; missing measurements remain null. // @Tags Turns // @Produce json // @Security BearerAuth @@ -87,11 +88,8 @@ func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { if err := json.Unmarshal(session.Configuration, &cfg); err != nil || cfg.Agent.ID == "" { return v1.Turn{}, errors.New("missing stored agent identity") } + // Session Turns are root Turns, so subagent_id is always null here. response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)} - if turn.SubagentID != "" { - response.AgentID = turn.SubagentID - response.SubagentID = &turn.SubagentID - } if turn.Status == store.TurnFailed { // Native errors can contain secrets; publish a stable category without raw diagnostics. response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} diff --git a/services/agents-api/internal/db/queries/turn_reads.sql b/services/agents-api/internal/db/queries/turn_reads.sql index 2b76c9f68..a122c7a50 100644 --- a/services/agents-api/internal/db/queries/turn_reads.sql +++ b/services/agents-api/internal/db/queries/turn_reads.sql @@ -1,5 +1,7 @@ --- name: ListTurns :many -SELECT t.* FROM public_execution_turns t JOIN sessions s ON s.id = t.session_id +-- name: ListRootTurns :many +-- Session Turn reads carry root work only. Child Turns remain in subagent_turns +-- and are read through the Subagent queries. +SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.tenant_id = sqlc.arg(tenant_id) AND t.session_id = sqlc.arg(session_id) AND (sqlc.narg(after_created)::timestamptz IS NULL OR (NOT sqlc.arg(ascending)::boolean AND (t.created_at, t.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) diff --git a/services/agents-api/internal/db/sqlc/turn_reads.sql.go b/services/agents-api/internal/db/sqlc/turn_reads.sql.go index 5cab1dba9..dae4de7eb 100644 --- a/services/agents-api/internal/db/sqlc/turn_reads.sql.go +++ b/services/agents-api/internal/db/sqlc/turn_reads.sql.go @@ -11,8 +11,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const listTurns = `-- name: ListTurns :many -SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.token_usage, t.artifact_capture_started, t.subagent_id FROM public_execution_turns t JOIN sessions s ON s.id = t.session_id +const listRootTurns = `-- name: ListRootTurns :many +SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.tenant_id = $1 AND t.session_id = $2 AND ($3::timestamptz IS NULL OR (NOT $4::boolean AND (t.created_at, t.id) < ($3::timestamptz, $5::uuid)) @@ -25,7 +25,7 @@ ORDER BY LIMIT $6 ` -type ListTurnsParams struct { +type ListRootTurnsParams struct { TenantID pgtype.UUID `json:"tenant_id"` SessionID pgtype.UUID `json:"session_id"` AfterCreated pgtype.Timestamptz `json:"after_created"` @@ -34,8 +34,10 @@ type ListTurnsParams struct { PageLimit int32 `json:"page_limit"` } -func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]PublicExecutionTurn, error) { - rows, err := q.db.Query(ctx, listTurns, +// Session Turn reads carry root work only. Child Turns remain in subagent_turns +// and are read through the Subagent queries. +func (q *Queries) ListRootTurns(ctx context.Context, arg ListRootTurnsParams) ([]Turn, error) { + rows, err := q.db.Query(ctx, listRootTurns, arg.TenantID, arg.SessionID, arg.AfterCreated, @@ -47,9 +49,9 @@ func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]PublicE return nil, err } defer rows.Close() - items := []PublicExecutionTurn{} + items := []Turn{} for rows.Next() { - var i PublicExecutionTurn + var i Turn if err := rows.Scan( &i.ID, &i.SessionID, @@ -59,9 +61,10 @@ func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]PublicE &i.CompletedAt, &i.CancelRequestedAt, &i.Outcome, + &i.EventCount, + &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, - &i.SubagentID, ); err != nil { return nil, err } diff --git a/services/agents-api/internal/store/runtime_idle_clock_test.go b/services/agents-api/internal/store/runtime_idle_clock_test.go index 69a1d5d45..079701876 100644 --- a/services/agents-api/internal/store/runtime_idle_clock_test.go +++ b/services/agents-api/internal/store/runtime_idle_clock_test.go @@ -144,8 +144,11 @@ func TestManagedIdleClockIgnoresChildHostSkewAndReplay(t *testing.T) { if err != nil || len(page.Data) != 1 { t.Fatal(page, err) } - read, err := s.GetTurn(t.Context(), owner.TenantID, owner.SessionID, page.Data[0].ID) - if err != nil || read.CompletedAt.UnixMilli() != source { + // Public child Turns have second precision; read the stored native time. + sessionID, _ := parseID(owner.SessionID) + turnID, _ := parseID(page.Data[0].ID) + read, err := s.queries.GetChildTurn(t.Context(), sqlc.GetChildTurnParams{SessionID: sessionID, ID: turnID}) + if err != nil || read.CompletedAt.Time.UnixMilli() != source { t.Fatal("child native timestamp rewritten", read, err) } }) diff --git a/services/agents-api/internal/store/subagent_reads.go b/services/agents-api/internal/store/subagent_reads.go index f8cf49220..432625884 100644 --- a/services/agents-api/internal/store/subagent_reads.go +++ b/services/agents-api/internal/store/subagent_reads.go @@ -91,9 +91,22 @@ func childTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, child, return row, err } -func publicChildTurn(row sqlc.SubagentTurn) v1.Turn { +// sessionAgentID returns the Session's Agent ID, which is the agent_id of every +// Turn in the Session, including child Turns. The official service projects a +// direct child's Turn this way; nested children follow the same rule unobserved. +func sessionAgentID(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (string, error) { + agent, err := q.SubagentRootAgent(ctx, session) + if err == nil && agent == "" { + err = errors.New("missing stored agent identity") + } + return agent, err +} + +// publicChildTurn identifies the child through subagent_id; agent_id is the +// Session's Agent ID. +func publicChildTurn(row sqlc.SubagentTurn, agent string) v1.Turn { child := uuid.UUID(row.SubagentID.Bytes).String() - value := v1.Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), AgentID: child, SubagentID: &child, Object: "agent.session.turn", Status: row.Status, CreatedAt: row.CreatedAt.Time.Unix()} + value := v1.Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), AgentID: agent, SubagentID: &child, Object: "agent.session.turn", Status: row.Status, CreatedAt: row.CreatedAt.Time.Unix()} if row.StartedAt.Valid { seconds := row.StartedAt.Time.Unix() value.StartedAt = &seconds @@ -116,8 +129,12 @@ func (s *Store) GetSubagentTurn(ctx context.Context, tenant, session, child, id return err } row, err := childTurn(ctx, q, sid, child, id) + if err != nil { + return err + } + agent, err := sessionAgentID(ctx, q, sid) if err == nil { - result = publicChildTurn(row) + result = publicChildTurn(row, agent) } return err }) @@ -150,12 +167,16 @@ func (s *Store) ListSubagentTurns(ctx context.Context, tenant, session, child, a if err != nil { return err } + agent, err := sessionAgentID(ctx, q, sid) + if err != nil { + return err + } result.HasMore = len(rows) > limit if result.HasMore { rows = rows[:limit] } for _, row := range rows { - result.Data = append(result.Data, publicChildTurn(row)) + result.Data = append(result.Data, publicChildTurn(row, agent)) } return nil }) diff --git a/services/agents-api/internal/store/subagent_resources_test.go b/services/agents-api/internal/store/subagent_resources_test.go index d74a0a59e..a903ce75e 100644 --- a/services/agents-api/internal/store/subagent_resources_test.go +++ b/services/agents-api/internal/store/subagent_resources_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "reflect" + "strings" "testing" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" @@ -78,15 +80,38 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { t.Fatal(turns, err) } tid := turns.Data[0].ID - if turns.Data[0].AgentID != child.ID || turns.Data[0].SubagentID == nil || *turns.Data[0].SubagentID != child.ID || turns.Data[0].Usage != nil { + // agent_id is the Session's Agent ID; subagent_id identifies the child. + if turns.Data[0].AgentID != "agent_root" || turns.Data[0].SubagentID == nil || *turns.Data[0].SubagentID != child.ID || turns.Data[0].Usage != nil { t.Fatal(turns) } - same, err := s.GetTurn(ctx, tenant, session.ID, tid) - if err != nil || same.SubagentID != child.ID { + if same, err := s.GetSubagentTurn(ctx, tenant, session.ID, child.ID, tid); err != nil || !reflect.DeepEqual(same, turns.Data[0]) { t.Fatal(same, err) } + // Session Turn reads carry root work only: a child Turn ID is missing there. + if _, err = s.GetTurn(ctx, tenant, session.ID, tid); !errors.Is(err, ErrNotFound) { + t.Fatal("child Turn in Session Turn retrieval", err) + } + if _, err = s.ListTurns(ctx, tenant, session.ID, tid, 100, true); !errors.Is(err, ErrNotFound) { + t.Fatal("child Turn as a Session Turn cursor", err) + } allTurns, err := s.ListTurns(ctx, tenant, session.ID, "", 100, true) - if err != nil || len(allTurns.Turns) != 2 { + if err != nil || len(allTurns.Turns) != 1 || allTurns.Turns[0].ID != root.TurnID { + t.Fatal(allTurns, err) + } + // Nested children follow the same agent_id rule (unobserved officially). + nestedTurn := proto.SubagentTurnPayload{NativeID: "nested", TurnID: "native-nested-turn", Status: TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened} + appendFacts(subagentFact(proto.TypeSubagentTurn, nestedTurn)) + nestedTurns, err := s.ListSubagentTurns(ctx, tenant, session.ID, nested.ID, "", 20, true) + if err != nil || len(nestedTurns.Data) != 1 || nestedTurns.Data[0].AgentID != "agent_root" || *nestedTurns.Data[0].SubagentID != nested.ID { + t.Fatal(nestedTurns, err) + } + if _, err = s.GetSubagentTurn(ctx, uuid.NewString(), session.ID, child.ID, tid); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign tenant child Turn", err) + } + if _, err = s.ListSubagentTurns(ctx, uuid.NewString(), session.ID, child.ID, "", 20, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign tenant child Turns", err) + } + if allTurns, err = s.ListTurns(ctx, tenant, session.ID, "", 100, true); err != nil || len(allTurns.Turns) != 1 { t.Fatal(allTurns, err) } own, err := s.ListSubagentTurnItems(ctx, tenant, session.ID, child.ID, tid, "", 20, true) @@ -133,6 +158,13 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { if change.Event.Subagent != nil && change.Event.Subagent.ID == child.ID { counts[change.Event.Type]++ } + // Child Turns publish no Session Turn lifecycle events. + if strings.HasPrefix(change.Event.Type, "agent.session.turn.") && change.Event.Turn != nil && change.Event.Turn.SubagentID != nil { + t.Fatal("child Turn on the Session stream", change.Event.Type) + } + if change.Turn != nil && change.Turn.ID != root.TurnID { + t.Fatal("child Turn snapshot on the Session stream", change.Event.Type) + } } for _, kind := range []string{"created", "closed", "active"} { if counts["agent.session.subagent."+kind] != 1 { diff --git a/services/agents-api/internal/store/subagent_turn_projection.go b/services/agents-api/internal/store/subagent_turn_projection.go index 85481ed46..b8a464cc4 100644 --- a/services/agents-api/internal/store/subagent_turn_projection.go +++ b/services/agents-api/internal/store/subagent_turn_projection.go @@ -22,9 +22,6 @@ func nativeMillis(value *int64) pgtype.Timestamptz { } return pgtype.Timestamptz{Time: time.UnixMilli(*value), Valid: true} } -func childStoreTurn(row sqlc.SubagentTurn) Turn { - return Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), SubagentID: uuid.UUID(row.SubagentID.Bytes).String(), Status: row.Status, CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, Usage: row.TokenUsage} -} func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { var p proto.SubagentTurnPayload if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || p.CreatedAtMS <= 0 { @@ -85,22 +82,10 @@ func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UU return err } // Terminal replays returned above; they must not restart the managed idle timer. + // Child Turns publish no Session events: the Session stream carries root work, + // and child state is read through the Subagent routes. if terminalStatus(row.Status) { - if err := q.RecordRuntimeTerminalActivity(ctx, session); err != nil { - return err - } - } - value := publicChildTurn(row) - emit := func(kind string) error { - return recordSessionChange(ctx, q, session, SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, TurnID: value.ID, Turn: &value}}) - } - if fresh { - if err := emit("created"); err != nil { - return err - } - } - if (fresh || old.Status != row.Status) && row.Status != TurnWaiting { - return emit(row.Status) + return q.RecordRuntimeTerminalActivity(ctx, session) } return nil } diff --git a/services/agents-api/internal/store/turn_reads.go b/services/agents-api/internal/store/turn_reads.go index 4311ed653..7998e6723 100644 --- a/services/agents-api/internal/store/turn_reads.go +++ b/services/agents-api/internal/store/turn_reads.go @@ -14,6 +14,8 @@ type TurnPage struct { NextCursor string } +// ListTurns pages a Session's root Turns. Subagent Turns are not Session Turns; +// ListSubagentTurns reads them. func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor string, limit int, ascending bool) (TurnPage, error) { if limit < 1 || limit > 100 { return TurnPage{}, fmt.Errorf("%w: page size must be 1..100", ErrInvalidInput) @@ -23,12 +25,13 @@ func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor strin } tenant, _ := parseID(tenantID) session, _ := parseID(sessionID) - params := sqlc.ListTurnsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + params := sqlc.ListRootTurnsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} if cursor != "" { // A malformed cursor remains an invalid request, unlike a path identifier. if _, err := parseID(cursor); err != nil { return TurnPage{}, err } + // A child Turn is not a Session Turn, so its ID is a missing cursor here. after, err := s.GetTurn(ctx, tenantID, sessionID, cursor) if err != nil { return TurnPage{}, err @@ -36,7 +39,7 @@ func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor strin params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} params.AfterID, _ = parseID(after.ID) } - rows, err := s.queries.ListTurns(ctx, params) + rows, err := s.queries.ListRootTurns(ctx, params) if err != nil { return TurnPage{}, fmt.Errorf("list turns: %w", err) } @@ -46,13 +49,7 @@ func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor strin rows = rows[:limit] } for _, row := range rows { - value := Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: sessionID, Status: row.Status, - CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, - CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: row.Outcome, Usage: row.TokenUsage, ArtifactCaptureStarted: row.ArtifactCaptureStarted} - if row.SubagentID.Valid { - value.SubagentID = uuid.UUID(row.SubagentID.Bytes).String() - } - page.Turns = append(page.Turns, value) + page.Turns = append(page.Turns, turnFromRow(row)) } return page, nil } diff --git a/services/agents-api/internal/store/turns.go b/services/agents-api/internal/store/turns.go index a7f5c7700..51832f6e2 100644 --- a/services/agents-api/internal/store/turns.go +++ b/services/agents-api/internal/store/turns.go @@ -25,12 +25,13 @@ const ( TurnCancelled = "cancelled" ) -// Turn uses its Session's immutable execution configuration. Zero timestamps -// mean the corresponding event has not occurred. Outcome is adapter-owned data, -// not an upstream response; the API must project supported wire types explicitly. +// Turn is a root Turn from the Core work queue and uses its Session's immutable +// execution configuration; Subagent Turns have a native writer and their own +// table. Zero timestamps mean the corresponding event has not occurred. Outcome +// is adapter-owned data, not an upstream response; the API must project +// supported wire types explicitly. type Turn struct { ID, SessionID, Status string - SubagentID string CreatedAt time.Time StartedAt time.Time CompletedAt time.Time @@ -47,6 +48,8 @@ type TurnTransition struct { Outcome json.RawMessage } +// GetTurn reads a root Turn. A Subagent Turn ID is not found here, exactly like +// a missing one; GetSubagentTurn reads child Turns. func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) (Turn, error) { params, err := publicTurnLookup(tenantID, sessionID, turnID) if err != nil { @@ -54,18 +57,7 @@ func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) } row, err := s.queries.GetTurn(ctx, params) if errors.Is(err, pgx.ErrNoRows) { - // Native child work has a separate writer and never enters the Core queue. - if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { - return Turn{}, err - } - child, err := s.queries.GetChildTurn(ctx, sqlc.GetChildTurnParams{SessionID: params.SessionID, ID: params.ID}) - if errors.Is(err, pgx.ErrNoRows) { - return Turn{}, ErrNotFound - } - if err != nil { - return Turn{}, err - } - return childStoreTurn(child), nil + return Turn{}, ErrNotFound } if err != nil { return Turn{}, fmt.Errorf("get turn: %w", err) From a782b6e89e3228757bcc06c76ae6117b9f19fff7 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:18:48 +0000 Subject: [PATCH 02/11] Stop publishing child Item events on the Session stream Child Items recorded agent.session.turn.item.* and content events on the Session event log with the child Turn ID. The official parent stream carries neither child Turn nor child Item events (SAT-09, S1 and S2 frames), and the scan's "child Item events absent" reading missed this path. Child history stays readable through the Subagent Item routes; the stored output index is kept. --- .../store/subagent_item_projection.go | 10 ++--- .../store/subagent_native_outputs_test.go | 43 +++++++++++-------- .../internal/store/subagent_resources_test.go | 5 ++- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/services/agents-api/internal/store/subagent_item_projection.go b/services/agents-api/internal/store/subagent_item_projection.go index 237b6577d..11e4154a9 100644 --- a/services/agents-api/internal/store/subagent_item_projection.go +++ b/services/agents-api/internal/store/subagent_item_projection.go @@ -79,11 +79,11 @@ func putChildItem(ctx context.Context, q *sqlc.Queries, session, childID pgtype. if terminalStatus(turn.Status) { return ErrTurnConflict } - index, err := q.PutChildItem(ctx, sqlc.PutChildItemParams{ID: id, SessionID: session, SubagentID: childID, TurnID: turn.ID, Position: position, Payload: payload, IsOutput: item.Role != "user" && item.Type != "function_call_output"}) - if err != nil { - return err - } - return recordItemChange(ctx, q, session, index, previous, item, nil) + // Child Items publish no Session events: the Session stream carries root work, + // and child history is read through the Subagent routes. The stored output + // index keeps its existing meaning. + _, err = q.PutChildItem(ctx, sqlc.PutChildItemParams{ID: id, SessionID: session, SubagentID: childID, TurnID: turn.ID, Position: position, Payload: payload, IsOutput: item.Role != "user" && item.Type != "function_call_output"}) + return err } func childItems(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentItemPayload) ([]v1.Item, error) { diff --git a/services/agents-api/internal/store/subagent_native_outputs_test.go b/services/agents-api/internal/store/subagent_native_outputs_test.go index bcb361a57..7393e27ca 100644 --- a/services/agents-api/internal/store/subagent_native_outputs_test.go +++ b/services/agents-api/internal/store/subagent_native_outputs_test.go @@ -12,7 +12,7 @@ import ( ) func TestSubagentNativeFunctionResultDoesNotConsumeOutputIndex(t *testing.T) { - s, _ := testStore(t) + s, pool := testStore(t) owner := executionLease(t, s).Store() tenant, session := newSubagentSession(t, s) host, err := s.CreateDevice(t.Context(), tenant, "child outputs", device.HashCredential(uuid.NewString())) @@ -49,33 +49,38 @@ func TestSubagentNativeFunctionResultDoesNotConsumeOutputIndex(t *testing.T) { if err != nil || len(items.Data) != 3 { t.Fatal(items, err) } + // Child Items publish no Session events; only root work reaches the stream. events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) if err != nil { t.Fatal(err) } - found := map[string]bool{} for _, change := range events { e := change.Event - if e.Item == nil || e.Item.TurnID == input.TurnID { - continue + if (e.TurnID != "" && e.TurnID != input.TurnID) || (e.Item != nil && e.Item.TurnID != input.TurnID) { + t.Fatal("child work on the Session stream", e.Type) } - found[e.Item.Type] = true - switch e.Item.Type { - case "function_call_output": - if e.OutputIndex != nil { - t.Fatal("native tool result consumed output index", e) - } - case "function_call": - if e.OutputIndex == nil || *e.OutputIndex != 0 { - t.Fatal(e) - } - case "message": - if e.OutputIndex == nil || *e.OutputIndex != 1 { - t.Fatal(e) - } + } + rows, err := pool.Query(t.Context(), `SELECT payload->>'type', output_index FROM subagent_items WHERE session_id = $1`, session.ID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + found := map[string]*int32{} + for rows.Next() { + var kind string + var index *int32 + if err := rows.Scan(&kind, &index); err != nil { + t.Fatal(err) } + found[kind] = index + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(found) != 3 || found["function_call_output"] != nil { + t.Fatal("native tool result consumed output index", found) } - if len(found) != 3 { + if call, message := found["function_call"], found["message"]; call == nil || *call != 0 || message == nil || *message != 1 { t.Fatal(found) } } diff --git a/services/agents-api/internal/store/subagent_resources_test.go b/services/agents-api/internal/store/subagent_resources_test.go index a903ce75e..b6b4f79c8 100644 --- a/services/agents-api/internal/store/subagent_resources_test.go +++ b/services/agents-api/internal/store/subagent_resources_test.go @@ -158,10 +158,13 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { if change.Event.Subagent != nil && change.Event.Subagent.ID == child.ID { counts[change.Event.Type]++ } - // Child Turns publish no Session Turn lifecycle events. + // Child Turns and their Items publish no Session events. if strings.HasPrefix(change.Event.Type, "agent.session.turn.") && change.Event.Turn != nil && change.Event.Turn.SubagentID != nil { t.Fatal("child Turn on the Session stream", change.Event.Type) } + if (change.Event.TurnID != "" && change.Event.TurnID != root.TurnID) || (change.Event.Item != nil && change.Event.Item.TurnID != root.TurnID) { + t.Fatal("child work on the Session stream", change.Event.Type) + } if change.Turn != nil && change.Turn.ID != root.TurnID { t.Fatal("child Turn snapshot on the Session stream", change.Event.Type) } From 657c7133b4727bdbf1b9e44b52adcf851568a845 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:19:56 +0000 Subject: [PATCH 03/11] Use the common list envelope for Subagents and clamp child Item limits The Subagent list now returns object "list" with first_id and last_id (null on an empty page), built with listBounds like the Turn and Item lists (SAT-01). Subagent Item and Subagent Turn Item lists clamp limit 0 to 1 and values above 100 to 100, like Session Items (SAT-02); the Subagent list and Subagent Turn list keep rejecting out-of-range limits, as the official service does. OpenAPI is regenerated, including the root-only Session Turn and child agent_id descriptions from the previous commit. --- contracts/agents-api/openapi.yaml | 45 ++++++++++------ contracts/agents-api/v1/subagents.go | 3 ++ contracts/agents-api/v1/subagents_test.go | 19 ++++++- .../agents-api/internal/api/history_pages.go | 8 +++ .../agents-api/internal/api/subagent_turns.go | 8 +-- services/agents-api/internal/api/subagents.go | 11 ++-- .../agents-api/internal/api/subagents_test.go | 52 ++++++++++++++----- 7 files changed, 105 insertions(+), 41 deletions(-) diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 5837aeac7..1bbe6c048 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2412,11 +2412,22 @@ definitions: items: $ref: '#/definitions/v1.Subagent' type: array + first_id: + type: string + x-nullable: true has_more: type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string required: - data - has_more + - object type: object v1.SummaryText: properties: @@ -4576,7 +4587,7 @@ paths: /agents/sessions/{session_id}/subagents: get: description: Includes nested and closed Subagents. Cursors belong to the same - tenant and Session. + tenant and Session. A limit outside 1–100 is rejected. parameters: - description: agents=v1 in: header @@ -4718,10 +4729,9 @@ paths: name: after type: string - default: 20 - description: Page size + description: Page size; 0 is treated as 1 and values above 100 as 100 in: query - maximum: 100 - minimum: 1 + minimum: 0 name: limit type: integer - default: desc @@ -4767,8 +4777,9 @@ paths: - Subagents /agents/sessions/{session_id}/subagents/{subagent_id}/turns: get: - description: Includes this Subagent's Turns after resume. Cursors belong to - the same tenant, Session and Subagent. Missing recorded usage remains null. + description: Includes this Subagent's Turns after resume, with the Session's + Agent ID as agent_id. Cursors belong to the same tenant, Session and Subagent. + Missing recorded usage remains null. A limit outside 1–100 is rejected. parameters: - description: agents=v1 in: header @@ -4839,9 +4850,10 @@ paths: - Subagents /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}: get: - description: Returns the same canonical Turn exposed by the Session Turn endpoint, - scoped to the owning Subagent. Unknown or inaccessible parent scopes return - not found. + description: Returns a Turn owned by this Subagent. Its agent_id is the Session's + Agent ID and its subagent_id identifies the Subagent. Session Turn routes + do not return child Turns. Unknown or inaccessible parent scopes return not + found. parameters: - description: agents=v1 in: header @@ -4925,10 +4937,9 @@ paths: name: after type: string - default: 20 - description: Page size + description: Page size; 0 is treated as 1 and values above 100 as 100 in: query - maximum: 100 - minimum: 1 + minimum: 0 name: limit type: integer - default: desc @@ -4974,9 +4985,10 @@ paths: - Subagents /agents/sessions/{session_id}/turns: get: - description: Returns persisted state in creation order. The cursor belongs to - the same Session and tenant. Usage contains the latest recorded complete token - breakdown; missing measurements remain null. + description: Returns the Session's root Turns in creation order; Subagent Turns + are listed through the Subagent Turn routes. The cursor belongs to the same + Session and tenant. Usage contains the latest recorded complete token breakdown; + missing measurements remain null. parameters: - description: agents=v1 in: header @@ -5038,6 +5050,9 @@ paths: - Turns /agents/sessions/{session_id}/turns/{turn_id}: get: + description: Returns a root Turn of this Session. A Subagent Turn ID returns + the same not found error as a missing Turn; read it through the Subagent Turn + routes. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/v1/subagents.go b/contracts/agents-api/v1/subagents.go index a0388816e..1686b9c86 100644 --- a/contracts/agents-api/v1/subagents.go +++ b/contracts/agents-api/v1/subagents.go @@ -20,6 +20,9 @@ type Subagent struct { } type SubagentList struct { + Object string `json:"object" enums:"list" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` Data []Subagent `json:"data" binding:"required"` HasMore bool `json:"has_more" binding:"required"` } diff --git a/contracts/agents-api/v1/subagents_test.go b/contracts/agents-api/v1/subagents_test.go index 798c8c35c..bf89fc893 100644 --- a/contracts/agents-api/v1/subagents_test.go +++ b/contracts/agents-api/v1/subagents_test.go @@ -44,18 +44,33 @@ func TestTurnWireShapeIncludesNullableSubagentIdentity(t *testing.T) { t.Fatal(err) } assertJSONEqual(t, body, `{"id":"turn","agent_id":"root","subagent_id":null,"session_id":"session","object":"agent.session.turn","status":"completed","created_at":1700000000,"started_at":null,"completed_at":null,"error":null,"usage":null}`) + // A child Turn keeps the Session's Agent ID; subagent_id identifies the child. child := "child" - turn.AgentID, turn.SubagentID = child, &child + turn.SubagentID = &child body, err = json.Marshal(turn) if err != nil { t.Fatal(err) } var returned Turn - if err := json.Unmarshal(body, &returned); err != nil || returned.SubagentID == nil || *returned.SubagentID != child || returned.AgentID != child { + if err := json.Unmarshal(body, &returned); err != nil || returned.SubagentID == nil || *returned.SubagentID != child || returned.AgentID != "root" { t.Fatalf("child identity: %s (%v)", body, err) } } +func TestSubagentListUsesCommonEnvelope(t *testing.T) { + first := "child" + body, err := json.Marshal(SubagentList{Object: "list", FirstID: &first, LastID: &first, Data: []Subagent{{ID: first, Object: "agent.session.subagent", SessionID: "session", ParentAgentID: "root", OpenedAt: 1700000000, Status: "active"}}}) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, body, `{"object":"list","first_id":"child","last_id":"child","has_more":false,"data":[{"id":"child","object":"agent.session.subagent","session_id":"session","parent_agent_id":"root","opened_at":1700000000,"closed_at":null,"name":null,"instructions":null,"status":"active"}]}`) + body, err = json.Marshal(SubagentList{Object: "list", Data: []Subagent{}}) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, body, `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}`) +} + func TestCoordinationItemsMatchPinnedWireShapes(t *testing.T) { fixtures := []string{ `{"id":"create","turn_id":"turn","type":"create_subagent_call","status":"completed","agent_id":"root","content":[],"model":null,"reasoning_effort":null}`, diff --git a/services/agents-api/internal/api/history_pages.go b/services/agents-api/internal/api/history_pages.go index f88c643b9..b4149ce40 100644 --- a/services/agents-api/internal/api/history_pages.go +++ b/services/agents-api/internal/api/history_pages.go @@ -33,3 +33,11 @@ func itemListResponse(data []v1.Item, more bool) v1.ItemList { first, last := listBounds(data, func(value v1.Item) string { return value.ID }) return v1.ItemList{Object: "list", Data: data, HasMore: more, FirstID: first, LastID: last} } + +func subagentListResponse(data []v1.Subagent, more bool) v1.SubagentList { + if data == nil { + data = []v1.Subagent{} + } + first, last := listBounds(data, func(value v1.Subagent) string { return value.ID }) + return v1.SubagentList{Object: "list", Data: data, HasMore: more, FirstID: first, LastID: last} +} diff --git a/services/agents-api/internal/api/subagent_turns.go b/services/agents-api/internal/api/subagent_turns.go index 2ec1d7cab..f05958f34 100644 --- a/services/agents-api/internal/api/subagent_turns.go +++ b/services/agents-api/internal/api/subagent_turns.go @@ -7,7 +7,7 @@ import ( ) // @Summary Retrieve a Subagent Turn -// @Description Returns the same canonical Turn exposed by the Session Turn endpoint, scoped to the owning Subagent. Unknown or inaccessible parent scopes return not found. +// @Description Returns a Turn owned by this Subagent. Its agent_id is the Session's Agent ID and its subagent_id identifies the Subagent. Session Turn routes do not return child Turns. Unknown or inaccessible parent scopes return not found. // @Tags Subagents // @Produce json // @Security BearerAuth @@ -31,7 +31,7 @@ func (h *Handler) getSubagentTurn(w http.ResponseWriter, r *http.Request) { } // @Summary List a Subagent's Turns -// @Description Includes this Subagent's Turns after resume. Cursors belong to the same tenant, Session and Subagent. Missing recorded usage remains null. +// @Description Includes this Subagent's Turns after resume, with the Session's Agent ID as agent_id. Cursors belong to the same tenant, Session and Subagent. Missing recorded usage remains null. A limit outside 1–100 is rejected. // @Tags Subagents // @Produce json // @Security BearerAuth @@ -67,13 +67,13 @@ func (h *Handler) listSubagentTurns(w http.ResponseWriter, r *http.Request) { // @Param subagent_id path string true "Subagent ID" // @Param turn_id path string true "Turn ID" // @Param after query string false "Last Item ID from the previous page" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param limit query int false "Page size; 0 is treated as 1 and values above 100 as 100" minimum(0) default(20) // @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.ItemList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}/items [get] func (h *Handler) listSubagentTurnItems(w http.ResponseWriter, r *http.Request) { - options, ok := readPage(w, r) + options, ok := readClampedPage(w, r) if !ok || !h.subagentsReady(w) { return } diff --git a/services/agents-api/internal/api/subagents.go b/services/agents-api/internal/api/subagents.go index 8d960fc5c..d5c1ba65b 100644 --- a/services/agents-api/internal/api/subagents.go +++ b/services/agents-api/internal/api/subagents.go @@ -63,7 +63,7 @@ func (h *Handler) getSubagent(w http.ResponseWriter, r *http.Request) { } // @Summary List Session Subagents -// @Description Includes nested and closed Subagents. Cursors belong to the same tenant and Session. +// @Description Includes nested and closed Subagents. Cursors belong to the same tenant and Session. A limit outside 1–100 is rejected. // @Tags Subagents // @Produce json // @Security BearerAuth @@ -85,10 +85,7 @@ func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - if page.Data == nil { - page.Data = []v1.Subagent{} - } - writeJSON(w, http.StatusOK, page) + writeJSON(w, http.StatusOK, subagentListResponse(page.Data, page.HasMore)) } // @Summary List a Subagent's Items @@ -100,13 +97,13 @@ func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { // @Param session_id path string true "Session ID" // @Param subagent_id path string true "Subagent ID" // @Param after query string false "Last Item ID from the previous page" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param limit query int false "Page size; 0 is treated as 1 and values above 100 as 100" minimum(0) default(20) // @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.ItemList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/subagents/{subagent_id}/items [get] func (h *Handler) listSubagentItems(w http.ResponseWriter, r *http.Request) { - options, ok := readPage(w, r) + options, ok := readClampedPage(w, r) if !ok || !h.subagentsReady(w) { return } diff --git a/services/agents-api/internal/api/subagents_test.go b/services/agents-api/internal/api/subagents_test.go index 0a5f8cf9d..0ce549b30 100644 --- a/services/agents-api/internal/api/subagents_test.go +++ b/services/agents-api/internal/api/subagents_test.go @@ -32,9 +32,11 @@ func sampleSubagent() v1.Subagent { return v1.Subagent{ID: "child", Object: "agent.session.subagent", SessionID: "session", ParentAgentID: "root", OpenedAt: 1700000000, Status: "active"} } +// sampleSubagentTurn has the official child Turn shape: agent_id is the Session's +// Agent ID and subagent_id identifies the child. func sampleSubagentTurn() v1.Turn { id := "child" - return v1.Turn{ID: "turn", AgentID: id, SubagentID: &id, SessionID: "session", Object: "agent.session.turn", Status: "completed", CreatedAt: 1700000001} + return v1.Turn{ID: "turn", AgentID: "root", SubagentID: &id, SessionID: "session", Object: "agent.session.turn", Status: "completed", CreatedAt: 1700000001} } func (s *subagentReadStore) GetSubagent(_ context.Context, tenant, session, subagent string) (v1.Subagent, error) { @@ -81,16 +83,18 @@ func (s *subagentReadStore) items() v1.ItemList { return v1.ItemList{Data: []v1.Item{{ID: "item", TurnID: "turn", Type: "agent_message", SenderAgentID: "child", RecipientAgentID: "root", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}}}, HasMore: true} } +// Item lists clamp limit like Session Items; the Subagent and Subagent Turn lists +// reject it, as the official service does. var subagentRoutes = []struct { path, method, subagent, turn string - list bool + list, clamped bool }{ - {"", "list", "", "", true}, - {"/child", "get", "child", "", false}, - {"/child/items", "items", "child", "", true}, - {"/child/turns", "turns", "child", "", true}, - {"/child/turns/turn", "turn", "child", "turn", false}, - {"/child/turns/turn/items", "turn_items", "child", "turn", true}, + {"", "list", "", "", true, false}, + {"/child", "get", "child", "", false, false}, + {"/child/items", "items", "child", "", true, true}, + {"/child/turns", "turns", "child", "", true, false}, + {"/child/turns/turn", "turn", "child", "turn", false, false}, + {"/child/turns/turn/items", "turn_items", "child", "turn", true, true}, } func requestSubagents(h http.Handler, path, auth, beta string) *httptest.ResponseRecorder { @@ -120,6 +124,10 @@ func TestSubagentRoutesPreserveAuthenticatedParentScope(t *testing.T) { if s.limit != 20 || s.ascending || s.after != "" || string(body["has_more"]) != "true" { t.Fatalf("list defaults: %+v %s", s, w.Body) } + // Every Subagent list uses the common envelope. + if string(body["object"]) != `"list"` || len(body["first_id"]) < 3 || string(body["first_id"]) != string(body["last_id"]) { + t.Fatalf("list envelope: %s", w.Body) + } w = requestSubagents(h, route.path+"?after=last&limit=2&order=asc", "Bearer test-api-key", "agents=v1") if w.Code != http.StatusOK || s.after != "last" || s.limit != 2 || !s.ascending { t.Fatalf("pagination: %+v %s", s, w.Body) @@ -130,7 +138,7 @@ func TestSubagentRoutesPreserveAuthenticatedParentScope(t *testing.T) { t.Fatalf("%s missing null: %s", field, w.Body) } } - } else if string(body["agent_id"]) != `"child"` || string(body["subagent_id"]) != `"child"` || string(body["usage"]) != "null" { + } else if string(body["agent_id"]) != `"root"` || string(body["subagent_id"]) != `"child"` || string(body["usage"]) != "null" { t.Fatalf("child Turn identity: %s", w.Body) } }) @@ -144,7 +152,11 @@ func TestSubagentRoutesRejectInvalidQueriesBeforeStore(t *testing.T) { if !route.list { continue } - for _, query := range []string{"limit=0", "limit=101", "limit=null", "limit=-1", "limit=2&limit=3", "order=random", "order=asc&order=desc", "after=a&after=b"} { + queries := []string{"limit=null", "limit=-1", "limit=2&limit=3", "order=random", "order=asc&order=desc", "after=a&after=b"} + if !route.clamped { + queries = append(queries, "limit=0", "limit=101") + } + for _, query := range queries { calls := s.calls w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") if w.Code != http.StatusBadRequest || s.calls != calls || !strings.Contains(w.Body.String(), `"code":"invalid_request_error"`) { @@ -154,6 +166,23 @@ func TestSubagentRoutesRejectInvalidQueriesBeforeStore(t *testing.T) { } } +func TestSubagentItemListsClampLimit(t *testing.T) { + s := &subagentReadStore{} + h, _, tenant := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + if !route.clamped { + continue + } + for query, limit := range map[string]int{"limit=0": 1, "limit=1": 1, "limit=100": 100, "limit=101": 100, "limit=100000": 100} { + calls := s.calls + w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.calls != calls+1 || s.method != route.method || s.limit != limit || s.tenant != tenant { + t.Fatalf("%s?%s: %d %s %+v", route.path, query, w.Code, w.Body, s) + } + } + } +} + func TestSubagentRoutesIgnoreUnknownQueryKeys(t *testing.T) { s := &subagentReadStore{} h, _, tenant := testHandler(t, WithSubagents(s)) @@ -224,9 +253,6 @@ func TestSubagentRoutesDistinguishEmptyFromUnavailable(t *testing.T) { if route.list { w = requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") expected := `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}` - if route.method == "list" { - expected = `{"data":[],"has_more":false}` - } if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != expected { t.Fatalf("empty page: %d %s", w.Code, w.Body) } From cafc965d3f00e9002dc0951fb8379233603973d8 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:26:58 +0000 Subject: [PATCH 04/11] Test Subagent visibility through HTTP and PostgreSQL One real-PostgreSQL HTTP test covers A1-A5 together: root-only Session Turn list/retrieve/cursor with child IDs answering like missing ones, the Session Agent ID on direct and nested child Turns, the Subagent list envelope and empty page, child Item limit clamping versus Subagent/Subagent Turn rejection, tenant B 404s, and creation/GET streams that carry root coordination and subagent.created but no child Turn or Item events while the creation stream still settles on the root idle. --- .../store/subagent_visibility_public_test.go | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 services/agents-api/internal/store/subagent_visibility_public_test.go diff --git a/services/agents-api/internal/store/subagent_visibility_public_test.go b/services/agents-api/internal/store/subagent_visibility_public_test.go new file mode 100644 index 000000000..d053c3741 --- /dev/null +++ b/services/agents-api/internal/store/subagent_visibility_public_test.go @@ -0,0 +1,305 @@ +package store_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type visibilityEvent struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Turn *struct { + ID string `json:"id"` + SubagentID *string `json:"subagent_id"` + } `json:"turn"` + Item *struct { + TurnID string `json:"turn_id"` + Type string `json:"type"` + } `json:"item"` +} + +// collectEvents returns every data frame until the stream ends or is stopped. +func collectEvents(t *testing.T, stream sseLines) <-chan []visibilityEvent { + t.Helper() + done := make(chan []visibilityEvent, 1) + go func() { + var events []visibilityEvent + for line := range stream.lines { + if data, ok := strings.CutPrefix(line, "data: "); ok { + var event visibilityEvent + if json.Unmarshal([]byte(data), &event) != nil { + event.Type = "invalid" + } + events = append(events, event) + } + } + done <- events + }() + return done +} + +func subagentFixture(kind string, value any) store.ExecutionEvent { + raw, _ := json.Marshal(value) + return store.ExecutionEvent{Kind: kind, Payload: raw} +} + +// Child work appears only where the official service shows it: Session Turn +// reads and streams carry root work, child Turns are read through the Subagent +// routes with the Session's Agent ID, Subagent lists use the common envelope and +// child Item lists clamp their limit. Tenant B sees none of it. +func TestSubagentVisibilityPublic(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "foreign", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(storeAdmission{s}), api.WithSubagents(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + client := pathIDClient{t: t, server: server} + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = lease.Close(t.Context()) }() + writer := lease.Store() + ctx := t.Context() + + created := openStream(t, server, token, http.MethodPost, "/v1/agents/sessions", + `{"agent":{"model":"test-model","multi_agent":{"enabled":true,"max_concurrent_subagents":2}},"environment":{"type":"none"},"input":"Delegate one task.","stream":true}`, "subagent-visibility") + defer created.stop() + if line := created.next(t); line != ": connected" { + t.Fatal(line) + } + if line := created.next(t); line != "event: agent.session.created" { + t.Fatal(line) + } + var snapshot struct { + Session struct { + ID string `json:"id"` + Agent struct { + ID string `json:"id"` + } `json:"agent"` + } `json:"session"` + } + if data, ok := strings.CutPrefix(created.next(t), "data: "); !ok || json.Unmarshal([]byte(data), &snapshot) != nil || snapshot.Session.Agent.ID == "" { + t.Fatal("invalid created snapshot", data) + } + session, agent := snapshot.Session.ID, snapshot.Session.Agent.ID + creation := collectEvents(t, created) + live := openStream(t, server, token, http.MethodGet, "/v1/agents/sessions/"+session+"/events", "", "") + defer live.stop() + if line := live.next(t); line != ": connected" { + t.Fatal(line) + } + observed := collectEvents(t, live) + + page, err := s.ListTurns(ctx, tenant, session, "", 10, true) + if err != nil || len(page.Turns) != 1 { + t.Fatal(page, err) + } + root := page.Turns[0].ID + host, err := s.CreateDevice(ctx, tenant, "subagent visibility", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = writer.BindSessionDevice(ctx, tenant, session, host.ID); err != nil { + t.Fatal(err) + } + if _, err = writer.TransitionTurn(ctx, tenant, session, root, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + identity := func(child, parent string, created int64) store.ExecutionEvent { + return subagentFixture(proto.TypeSubagentIdentity, proto.SubagentIdentityPayload{NativeID: child, ParentNativeID: parent, NativeCreatedAt: created, ParentTurnID: "native-root", SourceItemID: "spawn-" + child}) + } + opened, finished := int64(1700000001000), int64(1700000002000) + message := func(child, turn, id string, position int32) store.ExecutionEvent { + text := "answer " + id + payload, _ := json.Marshal(proto.OutputMessagePayload{ID: id, Status: "completed", Text: &text}) + return subagentFixture(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: child, TurnID: turn, ItemID: id, Position: position, Kind: proto.TypeOutputMessage, Payload: payload}) + } + facts := []store.ExecutionEvent{ + identity("child", "root", 1700000001), identity("nested", "child", 1700000001), + subagentFixture(proto.TypeSubagentCoordination, proto.SubagentCoordinationPayload{ID: "spawn", Kind: "create_subagent_call", Status: "completed"}), + subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: store.TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened}), + message("child", "child-turn", "first", 0), message("child", "child-turn", "second", 1), + subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: store.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), + subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "nested", TurnID: "nested-turn", Status: store.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), + subagentFixture(proto.TypeSubagentCoordination, proto.SubagentCoordinationPayload{ID: "wait", Kind: "wait_for_subagents_call", Status: "completed", Recipients: []string{"child"}}), + } + if err = writer.AppendTurnEvents(ctx, tenant, session, root, 1, facts); err != nil { + t.Fatal(err) + } + if _, err = writer.TransitionTurn(ctx, tenant, session, root, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnCompleted}); err != nil { + t.Fatal(err) + } + + decode := func(token, path string, status int, value any) string { + t.Helper() + code, raw := client.do(token, http.MethodGet, "/v1/agents/sessions/"+session+path, "", nil) + if code != status || (value != nil && json.Unmarshal([]byte(raw), value) != nil) { + t.Fatalf("GET %s: %d %s", path, code, raw) + } + return raw + } + type listPage struct { + Object string `json:"object"` + FirstID *string `json:"first_id"` + LastID *string `json:"last_id"` + HasMore bool `json:"has_more"` + Data []json.RawMessage `json:"data"` + } + ids := func(page listPage) []string { + var result []string + for _, raw := range page.Data { + var value struct{ ID string } + _ = json.Unmarshal(raw, &value) + result = append(result, value.ID) + } + return result + } + var subagents listPage + decode(token, "/subagents?order=asc", 200, &subagents) + children := ids(subagents) + if subagents.Object != "list" || len(children) != 2 || subagents.FirstID == nil || *subagents.FirstID != children[0] || subagents.LastID == nil || *subagents.LastID != children[1] || subagents.HasMore { + t.Fatal("Subagent list envelope", subagents) + } + var empty listPage + decode(token, "/subagents?order=asc&after="+children[1], 200, &empty) + if empty.Object != "list" || empty.FirstID != nil || empty.LastID != nil || empty.HasMore || len(empty.Data) != 0 { + t.Fatal("empty Subagent page", empty) + } + nativeChild, err := s.GetSubagentIdentity(ctx, tenant, session, "child") + if err != nil { + t.Fatal(err) + } + child := nativeChild.ID + type turnBody struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + SubagentID *string `json:"subagent_id"` + } + var childTurns struct { + Object string `json:"object"` + Data []turnBody `json:"data"` + } + decode(token, "/subagents/"+child+"/turns", 200, &childTurns) + if childTurns.Object != "list" || len(childTurns.Data) != 1 { + t.Fatal(childTurns) + } + childTurn := childTurns.Data[0].ID + for _, sub := range children { + var turns struct{ Data []turnBody } + decode(token, "/subagents/"+sub+"/turns", 200, &turns) + for _, turn := range turns.Data { + // agent_id is the Session's Agent ID for direct and nested children. + if turn.AgentID != agent || turn.SubagentID == nil || *turn.SubagentID != sub { + t.Fatal("child Turn identity", sub, turn) + } + var retrieved turnBody + decode(token, "/subagents/"+sub+"/turns/"+turn.ID, 200, &retrieved) + if retrieved.ID != turn.ID || retrieved.AgentID != agent || retrieved.SubagentID == nil || *retrieved.SubagentID != sub { + t.Fatal("child Turn retrieval", retrieved) + } + } + } + + // A1: Session Turn reads are root-only, and a child Turn ID is missing there. + var sessionTurns struct{ Data []turnBody } + decode(token, "/turns", 200, &sessionTurns) + if len(sessionTurns.Data) != 1 || sessionTurns.Data[0].ID != root || sessionTurns.Data[0].AgentID != agent || sessionTurns.Data[0].SubagentID != nil { + t.Fatal("Session Turns include child work", sessionTurns) + } + missing := uuid.NewString() + if decode(token, "/turns/"+childTurn, 404, nil) != decode(token, "/turns/"+missing, 404, nil) { + t.Fatal("child Turn retrieval differs from a missing Turn") + } + if decode(token, "/turns?after="+childTurn, 404, nil) != decode(token, "/turns?after="+missing, 404, nil) { + t.Fatal("child Turn cursor differs from a missing cursor") + } + decode(token, "/turns/"+root, 200, nil) + + // A5: child Item lists clamp; the Subagent and Subagent Turn lists reject. + for _, path := range []string{"/subagents/" + child + "/items", "/subagents/" + child + "/turns/" + childTurn + "/items"} { + var all, one, clamped listPage + decode(token, path+"?order=asc", 200, &all) + if len(all.Data) != 2 { + t.Fatal(path, all) + } + decode(token, path+"?order=asc&limit=0", 200, &one) + if one.Object != "list" || len(one.Data) != 1 || !one.HasMore || ids(one)[0] != ids(all)[0] || *one.FirstID != ids(all)[0] { + t.Fatal(path, "limit=0", one) + } + decode(token, path+"?order=asc&limit=101", 200, &clamped) + if len(clamped.Data) != 2 || clamped.HasMore { + t.Fatal(path, "limit=101", clamped) + } + } + for _, path := range []string{"/subagents", "/subagents/" + child + "/turns"} { + for _, limit := range []string{"0", "101"} { + if raw := decode(token, path+"?limit="+limit, 400, nil); !strings.Contains(raw, "limit must be between 1 and 100") { + t.Fatal(path, raw) + } + } + } + + // Tenant B cannot read any of these resources. + for _, path := range []string{"/turns", "/turns/" + root, "/turns/" + childTurn, "/subagents", "/subagents/" + child, + "/subagents/" + child + "/turns", "/subagents/" + child + "/turns/" + childTurn, + "/subagents/" + child + "/items?limit=0", "/subagents/" + child + "/turns/" + childTurn + "/items?limit=101"} { + if raw := decode(foreign, path, 404, nil); strings.Contains(raw, child) || strings.Contains(raw, childTurn) { + t.Fatal("foreign response exposes child work", path, raw) + } + } + + // A2: the creation stream settles on the root Turn's idle as before, and + // neither stream carries child Turn or child Item events. + var streamed []visibilityEvent + select { + case streamed = <-creation: + case <-time.After(10 * time.Second): + t.Fatal("creation stream did not settle") + } + live.stop() + var liveEvents []visibilityEvent + select { + case liveEvents = <-observed: + case <-time.After(10 * time.Second): + t.Fatal("GET stream did not stop") + } + for name, events := range map[string][]visibilityEvent{"creation": streamed, "GET": liveEvents} { + types := map[string]int{} + for _, event := range events { + types[event.Type]++ + if (event.TurnID != "" && event.TurnID != root) || (event.Turn != nil && (event.Turn.ID != root || event.Turn.SubagentID != nil)) || (event.Item != nil && event.Item.TurnID != root) { + t.Fatal(name, "stream carries child work", event) + } + if event.Item != nil && event.Item.Type == "create_subagent_call" { + types["create_subagent_call"]++ + } + } + if types["agent.session.subagent.created"] != 2 || types["create_subagent_call"] == 0 || types["agent.session.turn.completed"] != 1 || types["invalid"] != 0 { + t.Fatal(name, "stream lacks root coordination", types) + } + if last := events[len(events)-1]; name == "creation" && last.Type != "agent.session.idle" { + t.Fatal("creation stream did not end at the settled idle", last.Type) + } + } +} From 4555180dc7d50c80579b54fdcef50c318243b12a Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:28:17 +0000 Subject: [PATCH 05/11] Accept the official child Turn identity in the TypeScript client A child Turn carries the Session's Agent ID as agent_id and names the child in subagent_id (SAT-08). The Turn projection no longer requires subagent_id to equal agent_id; it requires a nonempty agent_id and a nonempty or null subagent_id. The earlier child-owned agent_id and child Turn stream snapshots from older Core releases are still accepted. --- packages/agents-client/src/client.ts | 3 ++- .../agents-client/src/history-projection.ts | 3 ++- .../src/history-recovery.test.ts | 20 +++++++++++++++---- packages/agents-client/src/types.ts | 2 ++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index a28516be5..b34d27cb9 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -1353,7 +1353,8 @@ function projectStreamEventSession( const usage = withUsage ? { usage: projectTokenUsage(value.usage, invalidStreamEvent) } : {}; if ( !sameResourceId(turn.id, turnId) || - // Native child history can first appear as a completed created snapshot. + // Core no longer streams child Turns. Earlier releases did, and native child + // history could first appear there as a completed created snapshot. (!(event.type === "agent.session.turn.created" && turn.subagent_id != null) && turn.status !== turnStatusByEvent[event.type]) || (immutable !== undefined && turn.subagent_id == null && !sameResourceId(turn.agent_id, immutable.agent.id)) ) return invalidStreamEvent(); diff --git a/packages/agents-client/src/history-projection.ts b/packages/agents-client/src/history-projection.ts index 6f05e5672..b58961971 100644 --- a/packages/agents-client/src/history-projection.ts +++ b/packages/agents-client/src/history-projection.ts @@ -190,7 +190,8 @@ export function projectAgentTurn(value: unknown, expectedSessionId: string, inva typeof value.id !== "string" || value.id === "" || (expectedTurnId !== undefined && !sameResourceId(value.id, expectedTurnId)) || typeof value.agent_id !== "string" || value.agent_id === "" || - !(value.subagent_id === undefined || value.subagent_id === null || (nonemptyString(value.subagent_id) && sameResourceId(value.subagent_id, value.agent_id))) || + // A child Turn carries the Session's Agent ID; subagent_id names the child. + !(value.subagent_id === undefined || value.subagent_id === null || nonemptyString(value.subagent_id)) || typeof value.session_id !== "string" || !sameResourceId(value.session_id, expectedSessionId) || value.object !== "agent.session.turn" || typeof value.status !== "string" || !turnStatuses.has(value.status) || !isNonnegativeInteger(value.created_at) || diff --git a/packages/agents-client/src/history-recovery.test.ts b/packages/agents-client/src/history-recovery.test.ts index b56a44fac..c7c95ddf9 100644 --- a/packages/agents-client/src/history-recovery.test.ts +++ b/packages/agents-client/src/history-recovery.test.ts @@ -65,8 +65,9 @@ const items: Record[] = [ ]; describe("history and live event projections", () => { + // Official child Turns keep the Session's Agent ID and name the child in subagent_id. it.each([null, "child"])("preserves %s subagent identity in reads and SSE", async (subagentId) => { - const value = turn({ agent_id: subagentId ?? "root", subagent_id: subagentId, usage }); + const value = turn({ agent_id: "root", subagent_id: subagentId, usage }); const client = new OpenAIAgentsClient({ fetch: vi.fn() .mockResolvedValueOnce(json(value)) .mockResolvedValueOnce(json({ data: [value], has_more: false })) @@ -84,8 +85,19 @@ describe("history and live event projections", () => { expect(await client.retrieveTurn("session", "turn")).toEqual(value); }); - it("retains completed child snapshots first observed in a creation stream", async () => { - const value = turn({ agent_id: "child", subagent_id: "child" }); + it("accepts the official child Turn shape and the earlier child-owned agent_id", async () => { + for (const value of [turn({ agent_id: "root", subagent_id: "child" }), turn({ agent_id: "child", subagent_id: "child" })]) { + const client = new OpenAIAgentsClient({ fetch: vi.fn() + .mockResolvedValueOnce(json(value)) + .mockResolvedValueOnce(json({ object: "list", data: [value], first_id: "turn", last_id: "turn", has_more: false })) }); + expect(await client.retrieveTurn("session", "turn")).toEqual(value); + expect((await client.listTurns("session")).data).toEqual([value]); + } + }); + + // Earlier Core releases streamed child Turns; current Core streams root work only. + it("retains completed child snapshots first observed in an earlier creation stream", async () => { + const value = turn({ agent_id: "root", subagent_id: "child" }); const created = { type: "agent.session.created", event_id: "created", session: session() }; const client = new OpenAIAgentsClient({ fetch: async () => sse([ created, turnEvent(value, "agent.session.turn.created"), turnEvent(value), @@ -142,7 +154,7 @@ describe("history and live event projections", () => { it.each([ turn({ session_id: "foreign" }), turn({ id: "other" }), - turn({ agent_id: "root", subagent_id: "child" }), turn({ subagent_id: "" }), + turn({ agent_id: "", subagent_id: "child" }), turn({ subagent_id: "" }), turn({ subagent_id: 1 }), turn({ usage: { input_tokens: 1 } }), turn({ native_session_id: "private" }), ])("rejects malformed or mismatched Turn retrieval", async (value) => { const client = new OpenAIAgentsClient({ fetch: async () => json(value) }); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 152a98fe6..7c2f40ce8 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -577,7 +577,9 @@ export type TurnStatus = "queued" | "in_progress" | "waiting" | "completed" | "f export interface AgentTurn { id: string; + /** The Session's Agent ID, for root and Subagent Turns alike. */ agent_id: string; + /** Set on Subagent Turns, which are read through the Subagent routes. */ subagent_id?: string | null; session_id: string; object: "agent.session.turn"; From 74c9b1d1dfa6c30c45a595133bf44036b932f9af Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:29:30 +0000 Subject: [PATCH 06/11] Keep the Core Web Session timeline root-only Core no longer lists or streams Subagent Turns for a Session, so the Web timeline and trace show root work only. The Web never presented child Turns as Subagent work; it has no Subagent view and does not need the Subagent routes. If an earlier Core still lists or streams child Turns, the durable loader skips them (keeping its cursor) and live snapshots ignore them, so the durable and live timelines agree. --- .../sessions/turns/turn-state.test.ts | 28 +++++++++++++++++++ .../src/features/sessions/turns/turn-state.ts | 14 ++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/sessions/turns/turn-state.test.ts b/apps/web/src/features/sessions/turns/turn-state.test.ts index a21bfd3d1..641ceddc5 100644 --- a/apps/web/src/features/sessions/turns/turn-state.test.ts +++ b/apps/web/src/features/sessions/turns/turn-state.test.ts @@ -60,6 +60,17 @@ describe("durable Turn loading", () => { await expect(listAllTurns(foreign, "session-1")).rejects.toThrow("outside the selected Session"); }); + it("keeps the timeline root-only when an earlier Core lists Subagent Turns", async () => { + const child = { ...turn("child-turn", "completed"), subagent_id: "subagent-1" }; + const root = { ...turn("root-turn", "completed"), subagent_id: null }; + const listTurns = vi.fn(async (_sessionId: string, options?: { after?: string }) => options?.after + ? { data: [turn("turn-2")], has_more: false } + : { data: [root, child], has_more: true, last_id: "child-turn" }); + + await expect(listAllTurns({ listTurns } as unknown as AgentCore, "session-1")).resolves.toEqual([root, turn("turn-2")]); + expect(listTurns).toHaveBeenNthCalledWith(2, "session-1", { after: "child-turn", limit: 100, order: "asc", signal: undefined }); + }); + it("deduplicates overlapping pages without regressing a terminal Turn", async () => { const listTurns = vi.fn(async (_sessionId: string, options?: { after?: string }) => options?.after ? { data: [turn("turn-1", "in_progress"), turn("turn-2")], has_more: false } @@ -118,6 +129,23 @@ describe("Turn live reconciliation", () => { turn_id: "item-turn", turn: turn("item-turn", "completed"), } as SessionEvent, "session-1")).toBeNull(); + for (const type of ["agent.session.turn.created", "agent.session.turn.completed"]) { + expect(matchingTurnSnapshot({ + type, + event_id: `child-${type}`, + session_id: "session-1", + turn_id: "child-turn", + turn: { ...turn("child-turn", "completed"), subagent_id: "subagent-1" }, + } as SessionEvent, "session-1")).toBeNull(); + } + const root = { ...turn("root-turn", "completed"), subagent_id: null }; + expect(matchingTurnSnapshot({ + type: "agent.session.turn.completed", + event_id: "root", + session_id: "session-1", + turn_id: "root-turn", + turn: root, + } as SessionEvent, "session-1")).toEqual(root); expect(matchingTurnSnapshot({ type: "agent.session.turn.completed", event_id: "mismatched-status", diff --git a/apps/web/src/features/sessions/turns/turn-state.ts b/apps/web/src/features/sessions/turns/turn-state.ts index 6720bbcac..481f4bc62 100644 --- a/apps/web/src/features/sessions/turns/turn-state.ts +++ b/apps/web/src/features/sessions/turns/turn-state.ts @@ -48,7 +48,15 @@ function isTurnForSession(value: unknown, sessionId: string): value is AgentTurn typeof turn.status === "string" && turnStatuses.has(turn.status as AgentTurn["status"]); } -/** Reads the complete durable Turn collection in server creation order. */ +/** + * Session timelines show root work. Subagent Turns belong to the Subagent routes; + * Core no longer returns or streams them for a Session, but earlier releases did. + */ +function isRootTurn(turn: AgentTurn): boolean { + return turn.subagent_id === undefined || turn.subagent_id === null; +} + +/** Reads the complete durable root Turn collection in server creation order. */ export async function listAllTurns( core: AgentCore, sessionId: string, @@ -68,6 +76,7 @@ export async function listAllTurns( if (!isTurnForSession(value, sessionId)) { throw new Error("The Agent core returned a Turn outside the selected Session."); } + if (!isRootTurn(value)) continue; const index = indexes.get(value.id); if (index === undefined) { indexes.set(value.id, turns.length); @@ -109,11 +118,12 @@ export function mergeDurableAndLiveTurns(durable: AgentTurn[], live: AgentTurn[] return live.reduce(upsertTurn, durable); } -/** Accepts only a scoped, known Turn snapshot carried by a Turn event. */ +/** Accepts only a scoped, known root Turn snapshot carried by a Turn event. */ export function matchingTurnSnapshot(event: SessionEvent, sessionId: string): AgentTurn | null { const type = typeof event.type === "string" ? event.type : ""; const expectedStatus = lifecycleEventStatus.get(type); if (!expectedStatus || !isTurnForSession(event.turn, sessionId) || event.turn.status !== expectedStatus) return null; + if (!isRootTurn(event.turn)) return null; if (event.session_id && event.session_id !== sessionId) return null; if (event.turn_id && event.turn_id !== event.turn.id) return null; return event.turn; From e85dc5756ec5b910913ce44216c6345f2f732eed Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 14:56:08 +0000 Subject: [PATCH 07/11] Check Subagent visibility in the Subagent acceptance script Inspection now runs A1-A5 as named visibility checks, records each result per phase and fails only after all of them, so a baseline run lists every difference: root-only Session Turn list/retrieve/cursor (child IDs answer like missing ones, also for the foreign project and through the pinned SDK), the Session Agent ID on child Turns, the list envelope with null IDs on an empty page, child Item limit clamping versus Subagent/Subagent Turn rejection, and a Session stream without child Turn or Item events that carries subagent.created. Creation now uses the streaming create as the observed stream; later input observes the GET stream opened before submission. Unknown list keys are now expected to be ignored, matching the merged list query tolerance batch; the script previously expected 400. --- scripts/agents-api-subagents-acceptance.py | 180 ++++++++++++++++++--- 1 file changed, 159 insertions(+), 21 deletions(-) diff --git a/scripts/agents-api-subagents-acceptance.py b/scripts/agents-api-subagents-acceptance.py index 7a5a4b885..624b47fbc 100644 --- a/scripts/agents-api-subagents-acceptance.py +++ b/scripts/agents-api-subagents-acceptance.py @@ -17,6 +17,18 @@ profile supports nested delegation; absence is recorded, not fabricated. All/full acceptance requires observed spawn, nested, close and resume facts; model prose is never accepted as evidence of a native action. +Child work must appear only where the official service shows it. Inspection +records each of these visibility checks under "visibility" (per phase) and fails +only after running all of them, so a baseline run reports every difference at once: +Session Turn list/retrieve/cursor carry root Turns only and a child Turn ID +answers like a missing one (also for the foreign project); child Turns carry the +Session's Agent ID with their own subagent_id; every list uses the object/first_id/ +last_id envelope with null IDs on an empty page; child Item lists clamp limit 0 +and 101 while the Subagent and Subagent Turn lists reject them; and the Session +stream observed while this script submitted input (the creation stream, or the +GET stream for later input) carries no child Turn or Item event, and carries +agent.session.subagent.created when children were spawned. + Evidence contains only IDs, timestamps, counts and named checks. No HTTP bodies, model output, API keys or provider configuration are written. Sessions are left for the operator to inspect and clean up. This script starts no service/runtime. @@ -29,6 +41,7 @@ from pathlib import Path import re import sys +import threading import time from urllib.parse import quote, urlsplit import uuid @@ -38,6 +51,44 @@ class AcceptanceFailure(Exception): pass +class StreamObserver: + """Summarizes Session events without retaining bodies. The reader is a daemon + thread; stop() reads what was observed even if the live stream stays open.""" + + def __init__(self, stream, kind): + self.stream, self.kind = stream, kind + self.events, self.session_id, self.error, self.ended, self.stopping = [], None, None, False, False + self.thread = threading.Thread(target=self.run, daemon=True) + self.thread.start() + + def run(self): + try: + for event in self.stream: + value = event.to_dict() + if value.get("type") == "agent.session.created": + self.session_id = (value.get("session") or {}).get("id") + turn, item = value.get("turn") or {}, value.get("item") or {} + self.events.append({"type": value.get("type"), "turn_ids": {v for v in ( + value.get("turn_id"), turn.get("id"), item.get("turn_id")) if v}, + "child_turn": turn.get("subagent_id") is not None}) + except Exception as error: + if not self.stopping: + self.error = type(error).__name__ + finally: + self.ended = True + + def stop(self): + if self.kind == "creation": + # The creation stream ends by itself once the Session settles. + self.thread.join(60) + if not self.ended: + # A GET stream stays open; closing it ends the reader with a connection error. + self.stopping = True + self.stream.close() + self.thread.join(5) + return list(self.events) + + def require(condition, code): if not condition: raise AcceptanceFailure(code) @@ -94,7 +145,7 @@ def checked(name): "invalid_base_url") require(token and foreign_token and token != foreign_token, "distinct_project_tokens_required") import httpx2 - from openai import OpenAI + from openai import NotFoundError, OpenAI client = OpenAI(base_url=base, api_key=token, max_retries=0, timeout=30, _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) foreign = OpenAI(base_url=base, api_key=foreign_token, max_retries=0, timeout=30, @@ -119,11 +170,10 @@ def endpoint(suffix=""): def raw(suffix, params=None, expected=200, other=False): response = http.get(endpoint(suffix), headers=other_headers if other else headers, params=params) require(response.status_code == expected, "raw_status_" + str(expected) + "_expected") - if expected == 200: - return response.json() body = response.json() - require(isinstance(body.get("error"), dict), "error_envelope_missing") - return None + if expected != 200: + require(isinstance(body.get("error"), dict), "error_envelope_missing") + return body def sdk_list(resource, *pos, **keywords): values, seen = [], set() @@ -158,6 +208,8 @@ def root_finished(before): require(turn["status"] not in ("failed", "cancelled"), "root_execution_failed") return turn if turn["status"] == "completed" else False + observers = {} + def submit(stage, prompt): require(report["phases"].get(stage) != "passed", "phase_already_passed") require(not report.get("pending_phase"), "pending_phase_requires_operator_reconciliation") @@ -172,12 +224,18 @@ def submit(stage, prompt): if harness: agent["x_agents_core"] = {"harness": harness} environment = json.loads(os.environ.get("AGENTS_API_ENVIRONMENT_JSON", '{"type":"none"}')) - session = sessions.create(agent=agent, environment=environment, input=prompt, - extra_headers={"Idempotency-Key": report["nonce"] + "-" + stage}) - report["session_id"] = identifier(session.id) + # The creation stream is the Session stream observed for this phase. + observer = StreamObserver(sessions.create(agent=agent, environment=environment, input=prompt, stream=True, + extra_headers={"Idempotency-Key": report["nonce"] + "-" + stage}), "creation") + observers[stage] = observer + wait_for(lambda: observer.session_id or observer.ended, "creation_stream_timeout", timeout=30) + require(observer.session_id, "creation_stream_missing_created_event") + report["session_id"] = identifier(observer.session_id) save() else: require(sessions.retrieve(sid()).agent.multi_agent.enabled, "existing_session_delegation_disabled") + # Open the live stream before submitting, as the stream is live-only. + observers[stage] = StreamObserver(sessions.events.stream(sid()), "events") sessions.events.create(sid(), events=[{"type": "agent.session.input.message", "input": [ {"role": "user", "content": [{"type": "input_text", "text": prompt}]}]}], idempotency_key=report["nonce"] + "-" + stage) @@ -187,6 +245,7 @@ def submit(stage, prompt): save() def page_check(suffix, expected): + """Pagination and scope checks that predate the visibility batch.""" expected_ids = [identifier(item["id"]) for item in expected] require(len(expected_ids) == len(set(expected_ids)), "duplicate_resource_id") for order in ("asc", "desc"): @@ -208,24 +267,58 @@ def page_check(suffix, expected): require(observed == (expected if order == "asc" else list(reversed(expected))), "sdk_raw_or_pagination_mismatch") defaults = raw(suffix) require(defaults["data"] == list(reversed(expected))[:20], "pagination_defaults_mismatch") + # Unknown list keys are ignored (list query tolerance, row A1). + require(raw(suffix, {"unknown": "value"})["data"] == defaults["data"], "unknown_list_key_not_ignored") raw(suffix, {"after": str(uuid.uuid4())}, expected=404) - for params in ({"limit": 0}, {"limit": 101}, {"order": "invalid"}, {"unknown": "value"}): - raw(suffix, params, expected=400) + raw(suffix, {"order": "invalid"}, expected=400) raw(suffix, other=True, expected=404) - def inspect(): + visibility = {} + + def visible(name, check): + """Runs one visibility check to completion and records its result.""" + try: + check() + result = "passed" + except AcceptanceFailure as error: + result = str(error) + # A check repeated per resource keeps its first failure. + if visibility.get(name, "passed") == "passed": + visibility[name] = result + + def envelope_check(suffix, expected, clamped): + ids = [item["id"] for item in expected] + window = ids[:100] + first = raw(suffix, {"order": "asc", "limit": 100}) + require(first.get("object") == "list" and first.get("first_id") == (window[0] if window else None) and + first.get("last_id") == (window[-1] if window else None), "list_envelope_mismatch") + if ids: + empty = raw(suffix, {"order": "asc", "after": ids[-1]}) + require(empty == {"object": "list", "data": [], "first_id": None, "last_id": None, "has_more": False}, + "empty_page_envelope_mismatch") + for limit, size in ((0, 1), (101, 100)): + if clamped: + page = raw(suffix, {"order": "asc", "limit": limit}) + require([item["id"] for item in page["data"]] == ids[:size] and page["has_more"] == (len(ids) > size), + "limit_" + str(limit) + "_not_clamped") + else: + raw(suffix, {"limit": limit}, expected=400) + + def inspect(stage=None): + visibility.clear() subs = children() require(subs, "fixture_not_observed_no_subagents") page_check("/subagents", subs) + visible("subagent_list_envelope_and_limit_rejection", lambda: envelope_check("/subagents", subs, False)) root_items = sdk_list(sessions.items, sid()) turns = all_turns() require(all("subagent_id" in turn for turn in turns), "turn_subagent_identity_field_missing") by_turn = {turn["id"]: turn for turn in turns} root_ids = {item["id"] for item in root_items} - require(all(by_turn.get(item["turn_id"], {}).get("subagent_id") is None and - item["turn_id"] in by_turn for item in root_items), "root_items_include_child_work") + require(all(item["turn_id"] in by_turn for item in root_items), "root_items_include_child_work") known = {sub["id"] for sub in subs} root = sessions.retrieve(sid()).agent.id + child_turn_ids = set() require(all(sub["session_id"] == sid() and sub["parent_agent_id"] in known | {root} for sub in subs), "subagent_parent_scope_mismatch") parents = {sub["id"]: sub["parent_agent_id"] for sub in subs} for child in parents: @@ -254,6 +347,8 @@ def inspect(): for item in child_items), "fixture_not_observed_child_model_output") page_check(suffix + "/turns", child_turns) page_check(suffix + "/items", child_items) + visible("child_turn_list_envelope_and_limit_rejection", lambda: envelope_check(suffix + "/turns", child_turns, False)) + visible("child_item_list_envelope_and_limit_clamp", lambda: envelope_check(suffix + "/items", child_items, True)) own_turn_ids = {turn["id"] for turn in child_turns} own_item_ids = {item["id"] for item in child_items} require(not own_item_ids & seen_items and all(item["turn_id"] in own_turn_ids for item in child_items), "items_cross_agent_boundary") @@ -261,13 +356,18 @@ def inspect(): collected = set() for turn in child_turns: tid = identifier(turn["id"]) - require(turn["subagent_id"] == child and turn["agent_id"] == child and turn["session_id"] == sid(), "child_turn_owner_mismatch") + child_turn_ids.add(tid) + require(turn["subagent_id"] == child and turn["session_id"] == sid(), "child_turn_owner_mismatch") + # The official child Turn keeps the Session's Agent ID (SAT-08). + visible("child_turn_session_agent_id", lambda: require(turn["agent_id"] == root, "child_turn_agent_id_not_session_agent")) fetched = sessions.subagents.turns.retrieve(tid, session_id=sid(), subagent_id=child).to_dict() - require(fetched == turn == by_turn.get(tid) == sessions.turns.retrieve(tid, session_id=sid()).to_dict(), "session_child_turn_identity_mismatch") + require(fetched == turn, "subagent_turn_retrieve_mismatch") require(raw(suffix + "/turns/" + tid) == turn, "raw_child_turn_mismatch") raw(suffix + "/turns/" + tid, other=True, expected=404) + visible("session_turn_routes_hide_child_turns", lambda: session_child_turn_check(tid)) items = sdk_list(sessions.subagents.turns.items, tid, session_id=sid(), subagent_id=child) page_check(suffix + "/turns/" + tid + "/items", items) + visible("child_turn_item_list_envelope_and_limit_clamp", lambda: envelope_check(suffix + "/turns/" + tid + "/items", items, True)) require(all(item["turn_id"] == tid for item in items), "turn_items_wrong_turn") require(items == [item for item in child_items if item["turn_id"] == tid], "per_turn_items_mismatch") collected.update(item["id"] for item in items) @@ -286,16 +386,54 @@ def inspect(): first, second = summaries[:2] raw("/subagents/" + first["id"] + "/turns/" + second["turn_ids"][0], expected=404) raw("/subagents/" + first["id"] + "/items", {"after": second["item_ids"][0]}, expected=404) + # Session Turn reads carry root work only (SAT-07). + visible("session_turns_root_only", lambda: require( + all(turn.get("subagent_id") is None and turn["agent_id"] == root for turn in turns) and + not child_turn_ids & set(by_turn), "session_turns_include_child_work")) + visible("session_turn_list_envelope_and_limit_rejection", lambda: envelope_check("/turns", turns, False)) + if stage in observers: + visible("session_stream_root_work_only", lambda: stream_check(stage, set(by_turn), child_turn_ids)) report.update(subagents=summaries, nested_ids=nested, root_item_count=len(root_items)) - for name in ("six_get_sdk_and_raw", "root_child_item_isolation", "session_child_turn_identity", - "pagination_asc_desc_after_limit", "invalid_and_wrong_scope_cursors", "foreign_project_scope"): + report.setdefault("visibility", {})[stage or "inspect"] = dict(sorted(visibility.items())) + for name in ("six_get_sdk_and_raw", "root_child_item_isolation", "pagination_asc_desc_after_limit", + "invalid_and_wrong_scope_cursors", "foreign_project_scope"): checked(name) if nested: checked("nested_parentage") + failed = sorted(name for name, result in visibility.items() if result != "passed") + require(not failed, "visibility_failed_" + "_".join(failed)) + for name in visibility: + checked(name) report["resources_passed"] = True report["phases"]["inspect"] = "passed" save() + def session_child_turn_check(tid): + # A child Turn ID answers exactly like a missing Turn, as a path and as a cursor. + missing = str(uuid.uuid4()) + require(raw("/turns/" + tid, expected=404) == raw("/turns/" + missing, expected=404), "child_turn_retrieve_differs_from_missing") + require(raw("/turns", {"after": tid}, expected=404) == raw("/turns", {"after": missing}, expected=404), "child_turn_cursor_differs_from_missing") + raw("/turns/" + tid, other=True, expected=404) + try: + sessions.turns.retrieve(tid, session_id=sid()) + except NotFoundError: + return + raise AcceptanceFailure("sdk_session_turn_retrieve_returned_child_turn") + + def stream_check(stage, root_turn_ids, child_turn_ids): + observer = observers[stage] + events = observer.stop() + counts = {} + for event in events: + counts[event["type"]] = counts.get(event["type"], 0) + 1 + report.setdefault("streams", {})[stage] = {"kind": observer.kind, "error": observer.error, "event_counts": dict(sorted(counts.items()))} + require(observer.error is None, "session_stream_failed") + referenced = set().union(*(event["turn_ids"] for event in events)) if events else set() + require(not referenced & child_turn_ids and not any(event["child_turn"] for event in events), "session_stream_carries_child_work") + require(referenced <= root_turn_ids, "session_stream_references_unknown_turn") + if stage in ("spawn", "spawn-direct"): + require(counts.get("agent.session.subagent.created", 0) >= 1, "fixture_not_observed_subagent_created_event") + marker = "subagent-proof-" + report["nonce"][:12] def spawn(): @@ -311,7 +449,7 @@ def spawn(): direct = [sub for sub in children() if sub["parent_agent_id"] == root] require(len(direct) >= 2, "fixture_not_observed_two_direct_children") require(all(sub["status"] == "active" and sub["closed_at"] is None for sub in direct), "spawned_child_not_active") - inspect() + inspect("spawn") report["phases"]["spawn"] = "passed" save() @@ -324,7 +462,7 @@ def spawn_direct(): "Do not create nested children, close or interrupt either child. " "Use no network or file tools.") wait_for(lambda: len(children()) >= 2, "fixture_not_observed_two_children", timeout=5) - inspect() + inspect("spawn-direct") report["phases"]["spawn-direct"] = "passed" save() @@ -349,7 +487,7 @@ def close_child(): require(closed["opened_at"] == before[child]["opened_at"] and isinstance(closed["closed_at"], int), "closed_lifecycle_mismatch") report.update(target_id=child, target_opened_at=closed["opened_at"], target_closed_at=closed["closed_at"], before_close_turns=histories[child]["turns"], before_close_items=histories[child]["items"]) - inspect() + inspect("close") report["phases"]["close"] = "passed" checked("close_preserves_identity_and_opened_at") save() @@ -369,7 +507,7 @@ def resume_child(): require(any(turn["id"] not in report["before_close_turns"] and turn["status"] == "completed" for turn in own_turns), "fixture_not_observed_resumed_turn_completion") require(set(report["before_close_items"]) <= {item["id"] for item in own_items}, "resume_lost_item_history") - inspect() + inspect("resume") report["phases"]["resume"] = "passed" checked("resume_same_id_opened_at_null_closed_at_and_retained_history") save() From bfbf934b7ed96aebc8fd56431fd589b0aa17601e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 15:01:14 +0000 Subject: [PATCH 08/11] Document Subagent visibility alignment subagents.md gains the A1-A5 matrix with official request IDs, the consumer audit behind removing child Turn and Item events, the unchanged scope and the acceptance boundary. List query semantics now clamp Subagent Item lists and record the sampled Subagent/Subagent Turn rejections. Operation evidence adds register U and updates rows 13-14 and 20-25. CONTRIBUTING, the history/events contract and the coverage ledger no longer describe mixed Session Turn pages or child Turn events. --- CONTRIBUTING.md | 25 +++--- contracts/agents-api/README.md | 10 +-- contracts/agents-api/history-events-usage.md | 27 ++++--- contracts/agents-api/list-query-semantics.md | 9 ++- contracts/agents-api/operation-evidence.md | 17 ++-- contracts/agents-api/subagents.md | 85 +++++++++++++++++--- 6 files changed, 127 insertions(+), 46 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c9202196..79300071d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1933,12 +1933,13 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti create/retrieve. Page by `(created_at, id)` with a same-tenant saved-Agent cursor; listing never resolves Sessions, product objects or execution capabilities. Reuse shared list-query parsing and its per-family limit policy. Agent, Session, - Item and Template lists treat limit 0 as 1 and larger limits as 100; Vault and - Credential lists also clamp negative limits; Turn, Subagent and Artifact lists - reject limits outside 1–100; Skill lists accept 0–100, where 0 returns an empty - page; Files accept 1–10000. Pages hold at most 100 records (Files 10000) with - accurate continuation. The local default is 20 (Files 10000). Return the - list envelope with data/has_more and first/last IDs (null for empty pages). + Item, Subagent Item and Template lists treat limit 0 as 1 and larger limits as + 100; Vault and Credential lists also clamp negative limits; Turn, Subagent, + Subagent Turn and Artifact lists reject limits outside 1–100; Skill lists accept + 0–100, where 0 returns an empty page; Files accept 1–10000. Pages hold at most + 100 records (Files 10000) with accurate continuation. The local default is 20 + (Files 10000). Return the list envelope with data/has_more and first/last IDs + (null for empty pages). Exact pinned upstream default/cap, empty-envelope and error semantics remain unverified; do not present local limits or generic SDK parsing as full conformance. - Session `agent_id` lookup uses the authenticated tenant. Copy the saved resource @@ -2100,8 +2101,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Core replaces complete valid token breakdowns and preserves the last committed measurement on interruption. Missing measurements remain unknown. Do not infer token consumption from context occupancy or estimated costs, or parse native - Raw payloads in Core. Session totals cover recorded root Turns; mixed root/child - Turn listings are not a summable accounting ledger. Native measurement coverage + Raw payloads in Core. Session totals cover recorded root Turns; Subagent Turn + listings are not a summable accounting ledger. Native measurement coverage and exact provider/model attribution remain explicit qualification boundaries. No separate public usage event or historical SSE replay is introduced. - The dispatcher is an internal entry point used by the standalone service worker. @@ -2174,8 +2175,12 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti assigns public IDs and projects them under the existing Session lock and leased execution journal. Native names, history parsing and outcome proof stay in adapters. Public GETs read persisted resources without starting native work. - Child Turns have a native writer and a separate table from the Core queue; - `public_execution_turns` provides the shared Session read/pagination view. + Child Turns have a native writer and a separate table from the Core queue. + Session Turn reads and the Session event stream carry root work only: read child + Turns and Items through the Subagent routes, and never publish child Turn or Item + events on the Session stream. A child Turn's `agent_id` is the Session's Agent + ID; `subagent_id` names the child. The migration-defined `public_execution_turns` + view has no public reader; do not reintroduce mixed Session Turn pages. Session Items stay root-owned; copied parent transcripts never become child work. Repeated effects are idempotent. Active includes idle; task completion, process release and cancellation cannot fabricate public closure. Native timestamps diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index d13c0dd38..60dced512 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -100,8 +100,8 @@ paths start at `/vaults`, not `/agents/vaults`. | sessions.items | list | Partial Item variants | | sessions.artifacts | retrieve, list, delete, content | Shared output capture and immutable stored reads/deletion on accepted Docker profiles and [qualified user-managed workflows](user-managed-runtime-v1.md) (prior Core-managed E2B evidence remains historical), including retained downloads after Runtime loss. [Aligned](official-semantics-alignment.md#artifact-capture-and-listing--september-23) output symlink skipping, unchanged-path non-republication, the list envelope and malformed filters; exact upstream defaults/errors, hard-link/special-file capture and cancellation-edge parity remain unverified | | sessions.subagents | retrieve, list | [Three-harness Docker reads, native lifecycle limits and real evidence](subagents.md); full multi-agent semantics remain partial | -| sessions.subagents.items | list | Qualified own-child history reads; full Item variants and live child streaming remain partial | -| sessions.subagents.turns | retrieve, list | Implemented; shared Session/child IDs | +| sessions.subagents.items | list | Qualified own-child history reads; [limit clamping and the list envelope](subagents.md#subagent-visibility--september-23-2026) aligned; full Item variants remain partial. Child work is not streamed on the Session, as observed officially | +| sessions.subagents.turns | retrieve, list | Implemented; child Turns carry the Session's Agent ID and are not Session Turns | | sessions.subagents.turns.items | list | Implemented; scoped persisted reads | | environments | retrieve | Three-harness colocated self-hosted implementation and qualified Docker hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps | | environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. [Aligned](environment-files.md#wire-alignment--september-23-2026) the 201 status, page envelope, query keys, empty pages for non-directory paths on local workspace readers, sampled path/token errors and pending hosted rejection; recursion, parent creation, overwrite and other errors remain partial | @@ -632,9 +632,9 @@ Codex publishes observed active-Turn snapshots before completion through this sa contract. Persisted measurements remain available after cancellation or worker restart; measurements never received by Core cannot be recovered this way. Unknown historical breakdowns are not backfilled, and a Session total includes only -recorded root-Turn measurements. Child Turns returned in a mixed history page are -not an additional accounting ledger. Costs and prices are outside this execution -contract. See [history, events and usage](history-events-usage.md) for client +recorded root-Turn measurements. Session Turn pages hold root Turns only; Subagent +Turn pages are not an additional accounting ledger. Costs and prices are outside +this execution contract. See [history, events and usage](history-events-usage.md) for client recovery rules, native measurement limits and bounded official-service evidence. ### Live events diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md index d116fba88..b74aff594 100644 --- a/contracts/agents-api/history-events-usage.md +++ b/contracts/agents-api/history-events-usage.md @@ -16,24 +16,29 @@ Do not permanently cache `usage: null` as zero or as a final accounting result. The TypeScript client shares Turn and Item projections between reads and SSE. It preserves root/child identity and supports the currently implemented reasoning and coordination Items. `agent_message` has no status; reasoning status can be -absent or null. Child discovery can publish an already terminal native Turn in a -`turn.created` snapshot; clients must not reinterpret it as newly queued work. +absent or null. A child Turn's `agent_id` is the Session's Agent ID and its +`subagent_id` names the child. The Session stream carries root work only: child +Turns and child Items publish no Session events, while `agent.session.subagent.*` +events and root coordination Items remain. Earlier Core releases streamed child +Turns, which could first appear as an already terminal `turn.created` snapshot; +the client still accepts that and must not reinterpret it as newly queued work. This does not widen the server's Item or interim reasoning-event coverage. -Claude and MiniMax retain their settlement-based child-history boundary; accepting -coordination events does not guarantee continuous child progress or a child Turn -event on every execution. Recover child state through the Subagent queries. +Claude and MiniMax retain their settlement-based child-history boundary. Recover +child state through the Subagent queries. Use response cursors to page history in the requested direction. Session Turn -lists can include root and child Turns. Root Items and child Items have separate -query resources; use the Subagent resources for child history. Tenant ownership -is enforced by Core for both queries and streams. +lists contain root Turns only; a child Turn ID on the Session Turn routes is not +found. Root Items and child Items have separate query resources; use the Subagent +resources for child Turns and history. See +[Subagent visibility](subagents.md#subagent-visibility--september-23-2026). +Tenant ownership is enforced by Core for both queries and streams. ## Measurement boundary Adapters publish cumulative measurements for the current execution through the existing neutral Usage contract. Core replaces a Turn's snapshot atomically; repeated snapshots, including terminal repeats, do not add consumption. The -Session total sums recorded root-Turn measurements, not mixed root/child lists. +Session total sums recorded root-Turn measurements, not Subagent Turn lists. It is best-effort accounting, not an invoice or an estimate of missing work. - Codex publishes observed snapshots while the Turn is active. Exact native @@ -169,7 +174,9 @@ stream differences EVT-01..04; the plan is - **Terminal usage (EVT-03).** Official `agent.session.turn.completed` and `.cancelled` (8/8) carried a top-level `usage`, null at emission even when later reads were measured. Core terminal Turn events (`completed`, `failed`, - `cancelled`), root and child, now carry `usage` copied from the rendered Turn + `cancelled`), then root and child (child Turn events were later removed by the + [Subagent visibility batch](subagents.md#subagent-visibility--september-23-2026)), + now carry `usage` copied from the rendered Turn snapshot, with explicit null when unknown. Other events omit it. Codex can therefore publish measured counters at settlement, while Claude and MiniMax stay null; no counter is derived or summed. The TypeScript client accepts the diff --git a/contracts/agents-api/list-query-semantics.md b/contracts/agents-api/list-query-semantics.md index 395478410..db5356395 100644 --- a/contracts/agents-api/list-query-semantics.md +++ b/contracts/agents-api/list-query-semantics.md @@ -117,8 +117,8 @@ deferred below. | B1 | Repeated supported key on Beta lists, including scalar `status` | 400, code `invalid_request_error`, param null, ``Failed to deserialize query string: duplicate field `` `` | VA-03: `req_83ee26a0b9ba4e84af8996a9346f261b`, `req_de6a02d9a9c547e490e6e53aeb45544d`, `req_92b924b186cf48488cf625638130bb16`; SES-16: `req_86ab2f32451a426399e76b21debeadba`; SFT-24: `req_794a86f4e72946dfb688a2e6f23fff32` | | B2 | Repeated supported key on Skills and Skill versions | 400, code `duplicate_parameter`, param ``, with the observed message | SFT-10: `req_36e64628c2a44b1198d00949c4ed6cc8` | | B3 | Repeated key on Files | Unchanged local `unsupported_parameter` rejection | SFT-18: `req_9a38e0628b4e40bd8e7202ac0880209d` accepted one identical repeated `purpose`; a single sample does not define which differing value wins | -| C1, C2 | `limit=0` and `limit>100` on Agents, Sessions, Items, Templates | Clamped to 1 and 100 | VA-01: `req_4954e90768b54c588167333e83f0a958`; VA-18: `req_d43d918872cb40b5b6e545582ea94205`, `req_2efee54ffd6f41179e294870b0e62e02`; SES-10: `req_4f1fb6cb479a4d1cb64486c72d250e9f`; SES-11: `req_5af4e60768b14c9eab284bce8638348a`; SES-12: `req_2f7cab87400348408ab8bb6e68789dc9`; SES-13: `req_b5ebab5691314bfba6ad59c84c42d04e`; SFT-23: `req_f63fef0e08c1462c8158c0ee8523a88d` | -| C3 | `limit` 0 or above 100 on Turns | 400, code `invalid_request_error`, param null, `limit must be between 1 and 100` | SES-14: `req_2793f57b6a454c399a031277b6a02e45` | +| C1, C2 | `limit=0` and `limit>100` on Agents, Sessions, Items, Templates; Subagent Items and Subagent Turn Items since the [Subagent visibility batch](subagents.md#subagent-visibility--september-23-2026) | Clamped to 1 and 100 | VA-01: `req_4954e90768b54c588167333e83f0a958`; VA-18: `req_d43d918872cb40b5b6e545582ea94205`, `req_2efee54ffd6f41179e294870b0e62e02`; SES-10: `req_4f1fb6cb479a4d1cb64486c72d250e9f`; SES-11: `req_5af4e60768b14c9eab284bce8638348a`; SES-12: `req_2f7cab87400348408ab8bb6e68789dc9`; SES-13: `req_b5ebab5691314bfba6ad59c84c42d04e`; SFT-23: `req_f63fef0e08c1462c8158c0ee8523a88d`; SAT-02: `req_6179ae6c1d1640d899ee4798e7f9fa57`, `req_7436104afbae4e73a0eb43b00ec9e660`, `req_32899313414b4031849a22cd2927f0ad` | +| C3 | `limit` 0 or above 100 on Turns, Subagents and Subagent Turns | 400, code `invalid_request_error`, param null, `limit must be between 1 and 100` | SES-14: `req_2793f57b6a454c399a031277b6a02e45`; SAT-02 rejections (campaign scan 3): `req_7df58d9579be4ee3ab7fdab55286aa05`, `req_b4321de4480c4a8e96b9ea285ff63a46`, `req_0f437ad4713d47a8af1f61a88636bf79`, `req_e9d476dd2a69472694cffc0851d0574c` | | C4 | Negative or non-integer `limit` on Beta lists other than Vaults and Credentials | 400, code `invalid_request_error`, param null, `Failed to deserialize query string: limit: invalid digit found in string` | VA-04: `req_1362046e9d69497da9c23ca69517a026`, `req_c384455192dc4a99a032299a91416a74`; SES-15: `req_918128738f3a47b69203ca091f9cb9ca` | | C5 | Vault and Credential `limit` | 0, negative and above-100 values keep the pinned clamp; a non-integer uses the C4 error | VA-04: `req_f08cda4e1b9048808be9465b9a56c4a4`, `req_e53033a2e01e4b87aa0bb8d32c932a44`; VA-06 (pin conflict): `req_2ca22663c9414afa920b5509d3574812`, `req_1d088639a3614f6ba545cd36497ff35c`; VA-18: `req_bc47269acf8143f7869908567272e433`, `req_62eaf8fd15e8483a98a9a09ebc4d5e51` | | C6 | Skills and Skill versions `limit` | `0` returns 200 with empty `data`, null first/last IDs and `has_more` true only if a resource follows the cursor; above 100 is `integer_above_max_value`, below 0 is `integer_below_min_value`, both with param `limit` | SFT-08: `req_b71c126adaf3437d9b01aee3ec913863`, `req_218a5d425b7047a190e2c5dc2e047e81`; SFT-09: `req_0ed2668ecab54253ab40d2655954de2c`, `req_4dd2e453717f4f82b5a7921a34d68b0f` | @@ -150,8 +150,9 @@ foreign or missing Environment returns 404 first. No rejected request writes. Families are still selected by path, as for order errors. Local choices for unsampled inputs: -- Subagent and Artifact lists keep rejecting 0 and values above 100, now with the - Turn error fields. +- Artifact lists keep rejecting 0 and values above 100, now with the Turn error + fields. Subagent lists were a local choice here too; campaign scan 3 later + sampled them, and the Subagent visibility batch applies rows C1–C3 above. - Beta limits above the signed 64-bit range still reject, with code `invalid_request_error` and `...limit: number too large to fit in target type`. A leading sign or any other non-digit input, including an empty value, uses the diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 3afe84213..74a431509 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -41,6 +41,7 @@ Repository paths below are relative to the inspected worktree; private evidence | L | [List query tolerance](list-query-semantics.md#list-query-tolerance--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-1/{vaults-agents,sessions,skills-files-templates}/findings.json`: owned-collection unknown/repeated keys, limit bounds, Vault status union and Files empty purpose, plus unknown keys on a deleted Vault read and Agent delete. Rows A1–D2 of that section; no model execution. | | G | [Environment Files wire alignment](environment-files.md#wire-alignment--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-10, 16, 18, 32, 34–39 with raw records under `official/` (labels `fc01`–`fc17`, `fl01`–`fl16`, `files-*-pending`) and `run1/`: three owned hosted Sessions, all deleted; first official Environment Files observations. Rows F1–F9 of that section. Go handler, real-PostgreSQL Worker, gateway/daemon and Rust helper tests without a model; live acceptance is recorded with the batch. | | M | [Agent configuration validation](official-semantics-alignment.md#agent-configuration-validation--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-01..07 with raw records under `official/validation-{B1,B2A,B2B,B3}.json`: one owned Agent (deleted) and 44 requests without a model, Session creates without input on `none`. Rows C1–C4 and K1–K3 of that section. Go handler, real-PostgreSQL no-write/tenant replay and pinned-SDK tests without a model; live acceptance is recorded with the batch. | +| U | [Subagent visibility](subagents.md#subagent-visibility--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` SAT-01, 02, 07, 08, 09 with raw records under `official/` (labels `R00`–`R33`, `C01`–`C06`, S1/S2 stream frames): two owned official Sessions with two child Turns, all deleted; the first official Subagent observations. Rows A1–A5 of that section; SAT-03/05/13/14 match, SAT-04/06/10 and SAT-12 stay deferred or unknown. Go store/API and real-PostgreSQL HTTP tests, TypeScript client and Core Web tests; live acceptance is recorded with the batch. | | Y | [Artifact capture and listing](official-semantics-alignment.md#artifact-capture-and-listing--september-23); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-50..62 with raw records under `official/` (labels `al01`–`al09`, `ar01`–`ar04`, `ac01`–`ac05`, `ad01`/`ad02`): three owned Sessions and two tiny Turns, all deleted; first official Artifact observations. Rows A1–A4 of that section: symlink skip, republication, list envelope and malformed filter. Rust link tests, real-PostgreSQL store/HTTP and pinned-SDK tests without a model; live acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | @@ -62,19 +63,19 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 10 | beta.agents.sessions.delete | P: deletion only of a durably idle or failed Session without required actions or pending input; a busy root Turn or pending reservation gives 409 `conflict_error` with no change (subagent child Turns and pending Environment file writes are not checked); owner repeat returns the same 200; owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409; Z SES-29 `q5-delete-repeat` 200, SES-30 `q5b-delete-while-in-progress` 409, `q5-delete-never-existed` 404, `q5-delete-while-running` 200 right after an `events.create` 202 | D recorded real cleanup; C cleanup separately recorded; Z DB matrix D1–D4 with no-write digest, admission lock race and pinned-SDK script | Core returns 409 right after an `events.create` 202 because it admits Turns synchronously (official 200); input awaiting its Environment cannot be cancelled publicly and is unobserved officially; physical purge/retention may end repeat idempotency; caller compute ownership preserved | | 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission | S `second-turn-create.json`, `events-empty/null.json`; H second-input | C Live real continuation/no-op; T qualified message/result/cancel workflows | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools | | 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress` | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage | Full SSE/Item variants/order (EVT-05..10 deferred); child deltas settle late; no replay guarantee or observer-disconnect proof for every state | -| 13 | beta.agents.sessions.turns.retrieve | P: persisted root/child Turn identity; malformed ID equals missing | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set; X SES-28 official `turn_` 404 | Recorded H/B scoped Turn recovery; C history uses list | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage | -| 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root+child history; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28 | C Live paging; H/B real child/root identity recorded; L DB tenant A/B | All interleavings, same-timestamp paging, interim/failed usage | +| 13 | beta.agents.sessions.turns.retrieve | P: persisted root Turn identity; malformed and child Turn IDs equal missing | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set; X SES-28 official `turn_` 404; U SAT-07 `C04` child Turn ID 404 | Recorded H/B scoped Turn recovery (B under the earlier mixed root/child contract); C history uses list; U DB child-ID 404 equal to missing, tenant B | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage; official 404 message text differs | +| 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root Turns only; a child Turn cursor equals a missing one; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28; U SAT-07 `R06` root-only list beside a child Turn | C Live paging; H/B real child/root identity recorded under the earlier mixed contract; L DB tenant A/B; U DB root-only list and cursor | All interleavings, same-timestamp paging, interim/failed usage | | 15 | beta.agents.sessions.items.list | P: scoped root Items, full envelope; limit 0/above 100 clamp within the pinned 1–100 page | S `items-final/empty-page/limit-high.json`; H both directions; L SES-12/13/15/16/17 | C Live; H/T recorded content/coordination/result variants; L DB tenant A/B | Full Item union. Newer turn_id filter excluded from pin | | 16 | beta.agents.sessions.artifacts.retrieve | P: immutable captured metadata; unchanged outputs keep their Artifact ID across Turns | Y HE-58/59 `ar01-retrieve`, missing and `ar03-cross-session` 404 (fields match; official `artifact_` IDs vs Core UUIDs) | D recorded live Docker/user-managed workspace output; Y DB unchanged-path ID and metadata stability | Error messages differ; hard-link/special-file capture edges unobserved | | 17 | beta.agents.sessions.artifacts.list | P: scoped stored list, common list envelope; later Turns publish only new, changed or no-remaining-Artifact paths; symlinks skipped; malformed `environment_id` gives an empty page | Y HE-50..56 `al01`–`al09`: Turn 1 capture with a skipped link, Turn 2 republication, envelope, order/cursor/limits, other and malformed filters | D recorded live output enumeration; Y Rust link tests, DB republication, HTTP envelope/filter/tenant and pinned-SDK checks | Unknown `after` (HE-57, ERROR-PROTOCOL-001); changed-bytes republication inferred; deleted-newest edge and paging during capture/delete unobserved | | 18 | beta.agents.sessions.artifacts.delete | P: stored deletion; the next Turn republishes a deleted path | Y HE-61 `ad01-delete`, `ad02-delete-repeat` 404, reads after delete 404; HE-52 deleted path republished by Turn 2 | D recorded workflow summary; Y DB deleted-then-unchanged and deleted-during-capture republication | In-flight deletion and physical retention parity | | 19 | beta.agents.sessions.artifacts.content | P: immutable download after Runtime loss | Y HE-60/62 `ac01-content`, `ac02-content-range` (Range ignored, full 200), `ac05` 404 after Session deletion | D recorded live retained download; Y DB earlier versions keep their bytes | Core's extra Content-Disposition; cancellation-edge capture, partial transfer and content after Environment expiry unobserved officially | -| 20 | beta.agents.sessions.subagents.retrieve | P: owned durable child identity/lifecycle | None located | B recorded Live six-read matrix | Full lifecycle/multi-agent parity; native close differences | -| 21 | beta.agents.sessions.subagents.list | P: direct/nested/closed child records | None located | B recorded Live scopes/pages/continuation | Publication timing/parent propagation and unsupported native nesting | -| 22 | beta.agents.sessions.subagents.items.list | P: child-owned history only | None located | B recorded Live child separation/recovery | Full child Item union; continuous child progress not qualified | -| 23 | beta.agents.sessions.subagents.turns.retrieve | P: scoped shared child Turn ID | None located | B recorded Live six-read matrix | Full status/usage/timestamp official semantics | -| 24 | beta.agents.sessions.subagents.turns.list | P: persisted child history | None located | B recorded Live paging/cold continuation | Full concurrent/cancel ordering and accounting | -| 25 | beta.agents.sessions.subagents.turns.items.list | P: exact child-and-Turn Items | None located | B recorded Live six-read matrix | Complete union and live ordering, overlapping mutation/cursors | +| 20 | beta.agents.sessions.subagents.retrieve | P: owned durable child identity/lifecycle | U `R01`, `R22`–`R26` (SAT-05/13 match; instructions hidden, SAT-11 ARCH) | B recorded Live six-read matrix; U DB tenant B 404 | Full lifecycle/multi-agent parity; native close differences; 404 message text (SAT-06) | +| 21 | beta.agents.sessions.subagents.list | P: direct/nested/closed child records; `object`/`first_id`/`last_id` envelope with null IDs on an empty page; limit outside 1–100 rejects | U SAT-01 `R00`, `R11`; SAT-03 `R12`/`R13` rejections | B recorded Live scopes/pages/continuation; U DB envelope, empty page, rejection, tenant B | Publication timing/parent propagation and unsupported native nesting; unknown `after` (SAT-04) | +| 22 | beta.agents.sessions.subagents.items.list | P: child-owned history only; limit 0/above 100 clamp | U SAT-02 `R14`/`R15`; SAT-12 `R02` (contents unknown) | B recorded Live child separation/recovery; U DB clamp, tenant B | Full child Item union; child input Item retained (SAT-12); continuous child progress is not streamed on the Session | +| 23 | beta.agents.sessions.subagents.turns.retrieve | P: scoped child Turn; `agent_id` is the Session Agent ID, `subagent_id` the child | U SAT-08 `R04`; `R27`/`R28` 404 | B recorded Live six-read matrix; U DB direct and nested child identity, tenant B | Nested-child `agent_id` unobserved; full status/usage/timestamp official semantics | +| 24 | beta.agents.sessions.subagents.turns.list | P: persisted child history with the Session Agent ID; limit outside 1–100 rejects | U SAT-08 `R03`; SAT-03 `R16`/`R17` rejections | B recorded Live paging/cold continuation; U DB identity and rejection | Full concurrent/cancel ordering and accounting; unknown `after` (SAT-04) | +| 25 | beta.agents.sessions.subagents.turns.items.list | P: exact child-and-Turn Items; limit 0/above 100 clamp | U SAT-02 `C05`; `R05` | B recorded Live six-read matrix; U DB clamp, tenant B | Complete union and live ordering, overlapping mutation/cursors | | 26 | beta.agents.environments.retrieve | P: durable status, safe configured installation metadata | None located | D/I/K recorded native readiness and metadata | All lifecycle timing and installation inventory; configured metadata is not arbitrary workspace discovery | | 27 | beta.agents.environments.files.create | P: inline/source-file copy to qualified workspace; 201; observed path, unknown-field and provisioning errors | G HE-10 success/nested/empty, HE-16 path forms and unknown field, HE-18 pending | F/D/K recorded real copy, hashes/consumption/retention; G handler tests | 50 MiB local bound and no parent creation (official 5 MiB, creates parents); overwrite/conflict messages, other body errors and unknown-write semantics | | 28 | beta.agents.environments.files.list | P: direct regular-file directory, opaque cursor; `page` envelope; unknown keys ignored (malformed query encoding still rejected locally), repeated keys rejected; missing, file and symlink paths list empty without following on local workspace readers (the Claude SDK adapter reader keeps 404/503); cleaned-path, token and provisioning errors | G HE-32 envelope, HE-34/35 query keys, HE-36/37 empty pages, HE-38/39 path and token errors, HE-18 pending | F/D/K recorded live workspace listing; G handler, Worker and native helper tests | 1,024-entry prefilter bound; no recursion (HE-30); exact defaults, unsampled path forms and mutation invalidation unknown | diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index 91ed24a8d..da3474c96 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -20,11 +20,22 @@ authentication and `OpenAI-Beta: agents=v1` as ordinary Session reads. | `/subagents/{subagent_id}/turns/{turn_id}` | One owned Turn | | `/subagents/{subagent_id}/turns/{turn_id}/items` | Only that child's Items in that Turn | -Lists use `after`, `limit` (1–100, default 20) and `order` (default `desc`). +Lists use `after`, `limit` (default 20) and `order` (default `desc`) and return +`object: "list"`, `data`, `first_id`, `last_id` (null on an empty page) and +`has_more`. The Subagent and Subagent Turn lists reject a limit outside 1–100; +the two Item lists treat 0 as 1 and larger values as 100, like Session Items. Cursors must belong to the requested tenant, Session, child and optional Turn. -Child Turns also appear in Session Turn reads with the same IDs. Their `agent_id` -and `subagent_id` identify the child. Root Turns have `subagent_id: null`. Session -Items remain root-owned; inherited native parent transcripts are not child work. + +Child work appears only on these routes. Session Turn list and retrieve return +root Turns only; a child Turn ID there, including as a list cursor, gets the same +404 as a missing Turn. A child Turn's `agent_id` is the Session's Agent ID (a +direct child's `parent_agent_id` and the `create_subagent_call` `agent_id`), and +its `subagent_id` identifies the child; nested children use the same rule, which +is not observed officially. Root Turns have `subagent_id: null`. Session Items +remain root-owned; inherited native parent transcripts are not child work. The +Session event stream carries root work only: child Turns and child Items publish +no `agent.session.turn.*` events. `agent.session.subagent.*` events and root +coordination Items are unchanged. See [Subagent visibility](#subagent-visibility--september-23-2026). Active includes idle. Successful close records native time; successful reopen preserves identity and `opened_at`, clears `closed_at`, and emits `active` once. @@ -52,8 +63,9 @@ coordination request to a nonexistent child preserves its opaque requested targe it does not create a Subagent or imply that the target exists. Child Turns have a native writer, so their storage is separate from the Core work -queue. A read-only SQL view joins root and child Turns for public pagination. -Child work never becomes a second queued Core execution. Public GETs read durable +queue. Session Turn reads query root Turns directly; the read-only SQL view from +migration 000051 that joins root and child Turns stays in the schema without a +public reader. Child work never becomes a second queued Core execution. Public GETs read durable resources; they neither start native processes nor replay execution. An adapter freezes root output before child settlement, keeps the existing native @@ -108,7 +120,8 @@ The three harnesses passed the same six GET checks with Python SDK 3.13.0 and raw HTTP against the independent Core, dedicated PostgreSQL and colocated Runtime. Checks include two real children with their own model output, ascending/descending pagination, scoped cursors, root/child Item separation, Session/child Turn identity -and cross-project denial. A new native process continued the same child without +(under the earlier contract that listed child Turns in Session Turn reads) and +cross-project denial. A new native process continued the same child without changing old IDs, timestamps or history. Public cancellation stopped actual child workspace writes, persisted cancelled Turns and left the Subagent active. Core restart preserved all previously captured resources byte-for-byte after JSON @@ -133,8 +146,9 @@ Evidence root on `zju_a100_2`: proofs are in `native-proof`, `claude-native` and `mcode-native`. The shared script `scripts/agents-api-subagents-acceptance.py --phase spawn-direct` validates common reads; its `resources_passed` and `requested_phase_passed` fields qualify that -phase. Its aggregate `passed` field additionally requires the optional Codex -close/reopen scenario. Do not require unsupported native close operations merely +phase. Since the visibility batch, `resources_passed` also requires every named +check recorded under `visibility`. Its aggregate `passed` field additionally +requires the optional Codex close/reopen scenario. Do not require unsupported native close operations merely to make that separate aggregate flag true. This batch does not rerun the E2B deployment matrix or establish live child-delta @@ -147,3 +161,56 @@ Still unconfirmed upstream semantics include root-completion child propagation, complete child-delta ordering and Session Usage aggregation. Unlimited background work across root Turns, complete multi-agent conformance and business Teams are not established by these six resource reads. + +## Subagent visibility — September 23, 2026 + +The pin is unchanged: SDK 3.13.0, commit `d7c41ef`, `agents=v1`. This batch +starts from main `b77249c`. Its plan is +`~/.parsar/remediation/20260923/subagent-visibility/PLAN.md`. The first owned +official Subagent evidence is +`~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` +(SAT-01, 02, 07, 08, 09), with raw records under `official/`. It covers two owned +Sessions and two child Turns, all deleted. + +| Row | Case | Core behavior | Evidence (finding: request ID) | +| --- | --- | --- | --- | +| A1 | Session `turns.list` and `turns.retrieve` | Root Turns only. A child Turn ID, as a path or a list cursor, returns the same 404 as a missing Turn. Subagent Turn list/retrieve and their Item lists still serve child Turns | SAT-07: `req_4bb89ada3457444f994e7a90374d114e` (root-only list), `req_85e7eb58da8e402c8103379ff5bb11d2` (child list), `req_8a599dc455014b0398d884dfa5cc289c` (child ID 404) | +| A2 | GET events and the creation stream | No `agent.session.turn.*` event for a child Turn, including its Item and content events. `agent.session.subagent.*` events and root coordination Items stay. The creation stream still ends on the root's settled idle | SAT-09: `req_e0f7fb0ca13f4eb98b4d677be046e1da`, `req_7a68fa8c18e344cfa0ed202df92a875e` (S1 20 and S2 43 frames, no child Turn or Item event) | +| A3 | Child Turn `agent_id` | The Session's Agent ID; `subagent_id` unchanged. Nested children follow the same rule (not observed) | SAT-08: `req_85e7eb58da8e402c8103379ff5bb11d2`, `req_fc10f0d1a2e84bd086f006c01aa7ee54` | +| A4 | Subagent list envelope | `object`, `data`, `first_id`, `last_id`, `has_more`; null IDs on an empty page | SAT-01: `req_089f86e8088d441380a22de2723e6179`, `req_5f79af4eaea44cb7ab4e92920e0f88c8` | +| A5 | `limit` 0 or above 100 | Subagent Item and Subagent Turn Item lists clamp to 1 and 100. The Subagent and Subagent Turn lists keep rejecting with `limit must be between 1 and 100` | SAT-02: `req_6179ae6c1d1640d899ee4798e7f9fa57`, `req_7436104afbae4e73a0eb43b00ec9e660`, `req_32899313414b4031849a22cd2927f0ad`; rejections `req_7df58d9579be4ee3ab7fdab55286aa05`, `req_b4321de4480c4a8e96b9ea285ff63a46`, `req_0f437ad4713d47a8af1f61a88636bf79`, `req_e9d476dd2a69472694cffc0851d0574c` | + +### Decisions + +- Session Turn reads use a new root-only query instead of changing the view, so no + migration is needed. Child data is not deleted or rewritten. +- The Session event log has one reader: the public GET and creation streams. + Creation-stream settlement reads the settled idle and the latest root Turn, and + Session usage sums root Turns, so neither used child Turn events. The Core Web + timeline had no Subagent view; it only showed child Turns as ordinary Turn rows + and now keeps its timeline root-only even against an earlier Core. Recovery + continues through Session, Turn and Item reads plus the Subagent routes. No + internal signal had to be kept. +- The scan recorded child Item events as already absent. They were not: every + child Item recorded `turn.item.*` and content events with the child Turn ID. + They are removed with the child Turn events, since the official parent stream + carried neither. +- The official Subagent list error message and the child Turn 404 message differ + from Core's local ones (`No managed agent resource found: …`). Only the status, + error fields and "same as missing" behavior are aligned here. + +Unchanged: Subagent retrieve fields and statuses, child history contents (SAT-12 +remains unknown; Core keeps the child input Item), the hidden task text, the +single `subagent.created` emission, cursor error semantics (SAT-04, HE-57), native +history ownership, cancellation, cold continuation and tenant isolation. + +### Acceptance boundary + +Go store and API tests cover each row, including tenant isolation. A real-PostgreSQL +HTTP test also checks creation-stream settlement with Subagent facts. TypeScript +client and Core Web unit tests cover the official child Turn shape and the +root-only timeline. `scripts/agents-api-subagents-acceptance.py` records A1–A5 +as named `visibility` checks per phase, including the observed stream. Its +inspect phase, run against a controlled local fixture without a model or stream, +reported all six read differences on baseline main and passed on this branch. Live model acceptance and the server gate are recorded with the +batch when complete. From 69062fcef17453a420fcee9f46767bbd12476f17 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 15:27:32 +0000 Subject: [PATCH 09/11] Wait for the root idle before stopping the GET stream in the visibility test The GET stream polls independently of the creation stream, so stopping it as soon as the creation stream settled could drop events not yet drained (about one run in ten). The test now stops it only after the stream delivered the idle that follows the root Turn's completion, with a bounded timeout. --- .../store/subagent_visibility_public_test.go | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/services/agents-api/internal/store/subagent_visibility_public_test.go b/services/agents-api/internal/store/subagent_visibility_public_test.go index d053c3741..9bde175b8 100644 --- a/services/agents-api/internal/store/subagent_visibility_public_test.go +++ b/services/agents-api/internal/store/subagent_visibility_public_test.go @@ -28,12 +28,19 @@ type visibilityEvent struct { } `json:"item"` } -// collectEvents returns every data frame until the stream ends or is stopped. -func collectEvents(t *testing.T, stream sseLines) <-chan []visibilityEvent { +type eventCollector struct { + // idle closes when agent.session.idle follows a Turn completion; done then + // receives every data frame once the stream ends or is stopped. + idle chan struct{} + done chan []visibilityEvent +} + +func collectEvents(t *testing.T, stream sseLines) eventCollector { t.Helper() - done := make(chan []visibilityEvent, 1) + collector := eventCollector{idle: make(chan struct{}), done: make(chan []visibilityEvent, 1)} go func() { var events []visibilityEvent + completed, idle := false, false for line := range stream.lines { if data, ok := strings.CutPrefix(line, "data: "); ok { var event visibilityEvent @@ -41,11 +48,16 @@ func collectEvents(t *testing.T, stream sseLines) <-chan []visibilityEvent { event.Type = "invalid" } events = append(events, event) + completed = completed || event.Type == "agent.session.turn.completed" + if completed && !idle && event.Type == "agent.session.idle" { + idle = true + close(collector.idle) + } } } - done <- events + collector.done <- events }() - return done + return collector } func subagentFixture(kind string, value any) store.ExecutionEvent { @@ -273,14 +285,21 @@ func TestSubagentVisibilityPublic(t *testing.T) { // neither stream carries child Turn or child Item events. var streamed []visibilityEvent select { - case streamed = <-creation: + case streamed = <-creation.done: case <-time.After(10 * time.Second): t.Fatal("creation stream did not settle") } + // The GET stream polls independently; stop it only after it has delivered the + // root Turn's idle, the last event this test records. + select { + case <-observed.idle: + case <-time.After(10 * time.Second): + t.Fatal("GET stream did not deliver the root idle") + } live.stop() var liveEvents []visibilityEvent select { - case liveEvents = <-observed: + case liveEvents = <-observed.done: case <-time.After(10 * time.Second): t.Fatal("GET stream did not stop") } From 2ad6103ad011ef6128c63562d2c20c70ec21b580 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 15:30:03 +0000 Subject: [PATCH 10/11] Hide Subagent Items from an earlier Core in the Web Session timeline An earlier Core still streams child Item and text events. Their Turns are filtered out of the timeline, so these Items appeared under unassociated Items in the trace. The Web now records Subagent Turn IDs from the durable Turn list and from streamed child Turn snapshots, per Session and Core connection, and hides Items of those Turns from the Session view. Current Core sends no child work to the Session, so nothing changes there. --- apps/web/src/App.tsx | 20 +++++++- .../sessions/turns/turn-state.test.ts | 44 +++++++++++++++- .../src/features/sessions/turns/turn-state.ts | 51 +++++++++++++++++-- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6280f1e15..42248d6e9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -87,9 +87,12 @@ import { type EnvironmentTemplateCatalog, } from "./features/sessions/environment/environment-templates"; import { + addChildTurnIds, + childTurnSnapshotId, listAllTurns, matchingTurnSnapshot, mergeDurableAndLiveTurns, + rootSessionItems, turnReadIsCurrent, upsertTurn, } from "./features/sessions/turns/turn-state"; @@ -276,6 +279,12 @@ export function App() { const [itemsSessionId, setItemsSessionId] = useState(null); const [turns, setTurns] = useState([]); const [turnsSessionId, setTurnsSessionId] = useState(null); + // Subagent Turn IDs an earlier Core listed or streamed; their Items stay hidden. + const [childTurnIds, setChildTurnIds] = useState>>(() => new Map()); + const rootItems = useMemo( + () => rootSessionItems(items, selectedId ? childTurnIds.get(selectedId) : undefined), + [childTurnIds, items, selectedId], + ); const [turnCollectionLoad, setTurnCollectionLoad] = useState({ sessionId: null, state: "idle", @@ -774,7 +783,11 @@ export function App() { const environmentRevision = environmentEventRevisionRef.current.get(sessionId) ?? 0; sessionRequestRef.current.set(sessionId, request); const turnRead = { coreGeneration, request, sessionId }; - void listAllTurns(core, sessionId, signal).then((sessionTurns) => { + const sessionChildTurnIds = new Set(); + void listAllTurns(core, sessionId, signal, sessionChildTurnIds).then((sessionTurns) => { + if (coreGeneration === connectionGenerationRef.current) { + setChildTurnIds((current) => addChildTurnIds(current, sessionId, sessionChildTurnIds)); + } const currentTurnRead = { coreGeneration: connectionGenerationRef.current, request: sessionRequestRef.current.get(sessionId) ?? 0, @@ -1038,6 +1051,7 @@ export function App() { setRuntimeCollectionHasSnapshot(false); setItems([]); setTurns([]); + setChildTurnIds(new Map()); setEnvironmentObservations(new Map()); itemsSessionIdRef.current = null; setItemsSessionId(null); @@ -1233,6 +1247,8 @@ export function App() { return next; }); } + const childTurnId = childTurnSnapshotId(event, sessionId); + if (childTurnId) setChildTurnIds((current) => addChildTurnIds(current, sessionId, [childTurnId])); const eventTurn = matchingTurnSnapshot(event, sessionId); if (eventTurn) { turnEventRevisionRef.current.set( @@ -2352,7 +2368,7 @@ export function App() { agentFilter={sessionAgentFilter} sessions={sessionBrowserSessions} selected={selected} - items={itemsSessionId === selectedId ? items : []} + items={itemsSessionId === selectedId ? rootItems : []} turns={turnsSessionId === selectedId ? turns : []} busy={busy} coreError={sessionBrowserError} diff --git a/apps/web/src/features/sessions/turns/turn-state.test.ts b/apps/web/src/features/sessions/turns/turn-state.test.ts index 641ceddc5..ccb0b81dc 100644 --- a/apps/web/src/features/sessions/turns/turn-state.test.ts +++ b/apps/web/src/features/sessions/turns/turn-state.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it, vi } from "vitest"; -import type { AgentCore, AgentTurn, SessionEvent } from "@agents-core-web/agents-client"; +import type { AgentCore, AgentTurn, SessionEvent, SessionItem } from "@agents-core-web/agents-client"; import { + addChildTurnIds, + childTurnSnapshotId, listAllTurns, matchingTurnSnapshot, mergeDurableAndLiveTurns, + rootSessionItems, turnReadIsCurrent, upsertTurn, } from "./turn-state"; @@ -67,8 +70,10 @@ describe("durable Turn loading", () => { ? { data: [turn("turn-2")], has_more: false } : { data: [root, child], has_more: true, last_id: "child-turn" }); - await expect(listAllTurns({ listTurns } as unknown as AgentCore, "session-1")).resolves.toEqual([root, turn("turn-2")]); + const childTurnIds = new Set(); + await expect(listAllTurns({ listTurns } as unknown as AgentCore, "session-1", undefined, childTurnIds)).resolves.toEqual([root, turn("turn-2")]); expect(listTurns).toHaveBeenNthCalledWith(2, "session-1", { after: "child-turn", limit: 100, order: "asc", signal: undefined }); + expect([...childTurnIds]).toEqual(["child-turn"]); }); it("deduplicates overlapping pages without regressing a terminal Turn", async () => { @@ -162,3 +167,38 @@ describe("Turn live reconciliation", () => { expect(turnReadIsCurrent(read, { ...read, selectedSessionId: "session-2" })).toBe(false); }); }); + +describe("Subagent work from an earlier Core", () => { + function item(id: string, turnId: string): SessionItem { + return { id, turn_id: turnId, type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: id }] } as SessionItem; + } + + it("recognizes streamed Subagent Turns, including a terminal first snapshot", () => { + const child = { ...turn("child-turn", "completed"), subagent_id: "subagent-1" }; + const event = (type: string, value: AgentTurn, sessionId = "session-1") => ({ + type, event_id: `${type}-${value.id}`, session_id: sessionId, turn_id: value.id, turn: value, + } as SessionEvent); + expect(childTurnSnapshotId(event("agent.session.turn.created", child), "session-1")).toBe("child-turn"); + expect(childTurnSnapshotId(event("agent.session.turn.completed", child), "session-1")).toBe("child-turn"); + expect(childTurnSnapshotId(event("agent.session.turn.completed", turn("root", "completed")), "session-1")).toBeNull(); + expect(childTurnSnapshotId(event("agent.session.turn.completed", child), "session-2")).toBeNull(); + expect(childTurnSnapshotId({ ...event("agent.session.turn.completed", child), turn_id: "other" }, "session-1")).toBeNull(); + expect(childTurnSnapshotId({ ...event("agent.session.turn.item.added", child) }, "session-1")).toBeNull(); + }); + + it("keeps live Items of known Subagent Turns out of the Session timeline", () => { + const empty: ReadonlyMap> = new Map(); + const known = addChildTurnIds(empty, "session-1", ["child-turn"]); + expect(addChildTurnIds(known, "session-1", ["child-turn"])).toBe(known); + expect(known.get("session-2")).toBeUndefined(); + + const rootItem = item("root-answer", "root-turn"); + // An earlier Core streamed a child Item and its text; neither may surface as + // an unassociated Item beside the root Turns. + const items = [rootItem, item("child-answer", "child-turn"), item("stream:child-turn:0:0", "child-turn")]; + expect(rootSessionItems(items, known.get("session-1"))).toEqual([rootItem]); + expect(rootSessionItems(items, known.get("session-2"))).toBe(items); + const rootOnly = [rootItem]; + expect(rootSessionItems(rootOnly, known.get("session-1"))).toBe(rootOnly); + }); +}); diff --git a/apps/web/src/features/sessions/turns/turn-state.ts b/apps/web/src/features/sessions/turns/turn-state.ts index 481f4bc62..b58ee8706 100644 --- a/apps/web/src/features/sessions/turns/turn-state.ts +++ b/apps/web/src/features/sessions/turns/turn-state.ts @@ -2,6 +2,7 @@ import type { AgentCore, AgentTurn, SessionEvent, + SessionItem, } from "@agents-core-web/agents-client"; const turnStatuses = new Set([ @@ -50,17 +51,22 @@ function isTurnForSession(value: unknown, sessionId: string): value is AgentTurn /** * Session timelines show root work. Subagent Turns belong to the Subagent routes; - * Core no longer returns or streams them for a Session, but earlier releases did. + * Core no longer returns or streams them, or their Items, for a Session, but + * earlier releases did. */ function isRootTurn(turn: AgentTurn): boolean { return turn.subagent_id === undefined || turn.subagent_id === null; } -/** Reads the complete durable root Turn collection in server creation order. */ +/** + * Reads the complete durable root Turn collection in server creation order. + * Subagent Turn IDs listed by an earlier Core are added to childTurnIds. + */ export async function listAllTurns( core: AgentCore, sessionId: string, signal?: AbortSignal, + childTurnIds?: Set, ): Promise { const turns: AgentTurn[] = []; const indexes = new Map(); @@ -76,7 +82,10 @@ export async function listAllTurns( if (!isTurnForSession(value, sessionId)) { throw new Error("The Agent core returned a Turn outside the selected Session."); } - if (!isRootTurn(value)) continue; + if (!isRootTurn(value)) { + childTurnIds?.add(value.id); + continue; + } const index = indexes.get(value.id); if (index === undefined) { indexes.set(value.id, turns.length); @@ -128,3 +137,39 @@ export function matchingTurnSnapshot(event: SessionEvent, sessionId: string): Ag if (event.turn_id && event.turn_id !== event.turn.id) return null; return event.turn; } + +/** + * Returns the Turn ID of a scoped Subagent Turn lifecycle event from an earlier + * Core. Its first snapshot can already be terminal, so the status is not checked. + */ +export function childTurnSnapshotId(event: SessionEvent, sessionId: string): string | null { + const type = typeof event.type === "string" ? event.type : ""; + if (!lifecycleEventStatus.has(type) || !isTurnForSession(event.turn, sessionId) || isRootTurn(event.turn)) return null; + if (event.session_id && event.session_id !== sessionId) return null; + if (event.turn_id && event.turn_id !== event.turn.id) return null; + return event.turn.id; +} + +/** Records Subagent Turn IDs per Session, keeping the same map when nothing is new. */ +export function addChildTurnIds( + current: ReadonlyMap>, + sessionId: string, + ids: Iterable, +): ReadonlyMap> { + const known = current.get(sessionId); + const added = [...ids].filter((id) => !known?.has(id)); + if (!added.length) return current; + const next = new Map(current); + next.set(sessionId, new Set([...(known ?? []), ...added])); + return next; +} + +/** + * Hides Items of known Subagent Turns, including live Item and text events an + * earlier Core streamed for them, so the Session timeline stays root-only. + */ +export function rootSessionItems(items: SessionItem[], childTurnIds: ReadonlySet | undefined): SessionItem[] { + if (!childTurnIds?.size) return items; + const visible = items.filter((item) => !childTurnIds.has(item.turn_id)); + return visible.length === items.length ? items : visible; +} From 992c4cec7ea5b10116852f593f01e2cffc1bdb18 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 15:31:32 +0000 Subject: [PATCH 11/11] Correct Subagent visibility evidence labels and wording The kept Subagent and Subagent Turn limit rejections (R12, R13, R16, R17) are SAT-03, not SAT-02. Record the current base, drop the stale child-delta note from the events.stream row, rewrite the EVT-03 usage sentence, note that older Cores send the child ID as a child Turn's agent_id, and state that documented Core extensions are unaffected; only undocumented mixed Session Turn pages and child Session events were removed. --- contracts/agents-api/history-events-usage.md | 9 ++++---- contracts/agents-api/list-query-semantics.md | 2 +- contracts/agents-api/operation-evidence.md | 4 ++-- contracts/agents-api/subagents.md | 23 +++++++++++++------- packages/agents-client/src/types.ts | 5 ++++- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md index b74aff594..6a30c5507 100644 --- a/contracts/agents-api/history-events-usage.md +++ b/contracts/agents-api/history-events-usage.md @@ -174,10 +174,11 @@ stream differences EVT-01..04; the plan is - **Terminal usage (EVT-03).** Official `agent.session.turn.completed` and `.cancelled` (8/8) carried a top-level `usage`, null at emission even when later reads were measured. Core terminal Turn events (`completed`, `failed`, - `cancelled`), then root and child (child Turn events were later removed by the - [Subagent visibility batch](subagents.md#subagent-visibility--september-23-2026)), - now carry `usage` copied from the rendered Turn - snapshot, with explicit null when unknown. Other events omit it. Codex can + `cancelled`) now carry `usage` copied from the rendered Turn snapshot, with + explicit null when unknown; other events omit it. This batch applied it to + root and child Turn events; the + [Subagent visibility batch](subagents.md#subagent-visibility--september-23-2026) + later stopped publishing child Turn events on the Session stream. Codex can therefore publish measured counters at settlement, while Claude and MiniMax stay null; no counter is derived or summed. The TypeScript client accepts the field on terminal Turn events only and still accepts older events without it. diff --git a/contracts/agents-api/list-query-semantics.md b/contracts/agents-api/list-query-semantics.md index db5356395..bc19d1d86 100644 --- a/contracts/agents-api/list-query-semantics.md +++ b/contracts/agents-api/list-query-semantics.md @@ -118,7 +118,7 @@ deferred below. | B2 | Repeated supported key on Skills and Skill versions | 400, code `duplicate_parameter`, param ``, with the observed message | SFT-10: `req_36e64628c2a44b1198d00949c4ed6cc8` | | B3 | Repeated key on Files | Unchanged local `unsupported_parameter` rejection | SFT-18: `req_9a38e0628b4e40bd8e7202ac0880209d` accepted one identical repeated `purpose`; a single sample does not define which differing value wins | | C1, C2 | `limit=0` and `limit>100` on Agents, Sessions, Items, Templates; Subagent Items and Subagent Turn Items since the [Subagent visibility batch](subagents.md#subagent-visibility--september-23-2026) | Clamped to 1 and 100 | VA-01: `req_4954e90768b54c588167333e83f0a958`; VA-18: `req_d43d918872cb40b5b6e545582ea94205`, `req_2efee54ffd6f41179e294870b0e62e02`; SES-10: `req_4f1fb6cb479a4d1cb64486c72d250e9f`; SES-11: `req_5af4e60768b14c9eab284bce8638348a`; SES-12: `req_2f7cab87400348408ab8bb6e68789dc9`; SES-13: `req_b5ebab5691314bfba6ad59c84c42d04e`; SFT-23: `req_f63fef0e08c1462c8158c0ee8523a88d`; SAT-02: `req_6179ae6c1d1640d899ee4798e7f9fa57`, `req_7436104afbae4e73a0eb43b00ec9e660`, `req_32899313414b4031849a22cd2927f0ad` | -| C3 | `limit` 0 or above 100 on Turns, Subagents and Subagent Turns | 400, code `invalid_request_error`, param null, `limit must be between 1 and 100` | SES-14: `req_2793f57b6a454c399a031277b6a02e45`; SAT-02 rejections (campaign scan 3): `req_7df58d9579be4ee3ab7fdab55286aa05`, `req_b4321de4480c4a8e96b9ea285ff63a46`, `req_0f437ad4713d47a8af1f61a88636bf79`, `req_e9d476dd2a69472694cffc0851d0574c` | +| C3 | `limit` 0 or above 100 on Turns, Subagents and Subagent Turns | 400, code `invalid_request_error`, param null, `limit must be between 1 and 100` | SES-14: `req_2793f57b6a454c399a031277b6a02e45`; SAT-03 (campaign scan 3): `req_7df58d9579be4ee3ab7fdab55286aa05`, `req_b4321de4480c4a8e96b9ea285ff63a46`, `req_0f437ad4713d47a8af1f61a88636bf79`, `req_e9d476dd2a69472694cffc0851d0574c` | | C4 | Negative or non-integer `limit` on Beta lists other than Vaults and Credentials | 400, code `invalid_request_error`, param null, `Failed to deserialize query string: limit: invalid digit found in string` | VA-04: `req_1362046e9d69497da9c23ca69517a026`, `req_c384455192dc4a99a032299a91416a74`; SES-15: `req_918128738f3a47b69203ca091f9cb9ca` | | C5 | Vault and Credential `limit` | 0, negative and above-100 values keep the pinned clamp; a non-integer uses the C4 error | VA-04: `req_f08cda4e1b9048808be9465b9a56c4a4`, `req_e53033a2e01e4b87aa0bb8d32c932a44`; VA-06 (pin conflict): `req_2ca22663c9414afa920b5509d3574812`, `req_1d088639a3614f6ba545cd36497ff35c`; VA-18: `req_bc47269acf8143f7869908567272e433`, `req_62eaf8fd15e8483a98a9a09ebc4d5e51` | | C6 | Skills and Skill versions `limit` | `0` returns 200 with empty `data`, null first/last IDs and `has_more` true only if a resource follows the cursor; above 100 is `integer_above_max_value`, below 0 is `integer_below_min_value`, both with param `limit` | SFT-08: `req_b71c126adaf3437d9b01aee3ec913863`, `req_218a5d425b7047a190e2c5dc2e047e81`; SFT-09: `req_0ed2668ecab54253ab40d2655954de2c`, `req_4dd2e453717f4f82b5a7921a34d68b0f` | diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 74a431509..70d2ccba2 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -41,7 +41,7 @@ Repository paths below are relative to the inspected worktree; private evidence | L | [List query tolerance](list-query-semantics.md#list-query-tolerance--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-1/{vaults-agents,sessions,skills-files-templates}/findings.json`: owned-collection unknown/repeated keys, limit bounds, Vault status union and Files empty purpose, plus unknown keys on a deleted Vault read and Agent delete. Rows A1–D2 of that section; no model execution. | | G | [Environment Files wire alignment](environment-files.md#wire-alignment--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-10, 16, 18, 32, 34–39 with raw records under `official/` (labels `fc01`–`fc17`, `fl01`–`fl16`, `files-*-pending`) and `run1/`: three owned hosted Sessions, all deleted; first official Environment Files observations. Rows F1–F9 of that section. Go handler, real-PostgreSQL Worker, gateway/daemon and Rust helper tests without a model; live acceptance is recorded with the batch. | | M | [Agent configuration validation](official-semantics-alignment.md#agent-configuration-validation--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-01..07 with raw records under `official/validation-{B1,B2A,B2B,B3}.json`: one owned Agent (deleted) and 44 requests without a model, Session creates without input on `none`. Rows C1–C4 and K1–K3 of that section. Go handler, real-PostgreSQL no-write/tenant replay and pinned-SDK tests without a model; live acceptance is recorded with the batch. | -| U | [Subagent visibility](subagents.md#subagent-visibility--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` SAT-01, 02, 07, 08, 09 with raw records under `official/` (labels `R00`–`R33`, `C01`–`C06`, S1/S2 stream frames): two owned official Sessions with two child Turns, all deleted; the first official Subagent observations. Rows A1–A5 of that section; SAT-03/05/13/14 match, SAT-04/06/10 and SAT-12 stay deferred or unknown. Go store/API and real-PostgreSQL HTTP tests, TypeScript client and Core Web tests; live acceptance is recorded with the batch. | +| U | [Subagent visibility](subagents.md#subagent-visibility--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` SAT-01, 02, 07, 08, 09 (SAT-03 for the kept limit rejections) with raw records under `official/` (labels `R00`–`R33`, `C01`–`C06`, S1/S2 stream frames): two owned official Sessions with two child Turns, all deleted; the first official Subagent observations. Rows A1–A5 of that section; SAT-03/05/13/14 match, SAT-04/06/10 and SAT-12 stay deferred or unknown. Go store/API and real-PostgreSQL HTTP tests, TypeScript client and Core Web tests; live acceptance is recorded with the batch. | | Y | [Artifact capture and listing](official-semantics-alignment.md#artifact-capture-and-listing--september-23); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-50..62 with raw records under `official/` (labels `al01`–`al09`, `ar01`–`ar04`, `ac01`–`ac05`, `ad01`/`ad02`): three owned Sessions and two tiny Turns, all deleted; first official Artifact observations. Rows A1–A4 of that section: symlink skip, republication, list envelope and malformed filter. Rust link tests, real-PostgreSQL store/HTTP and pinned-SDK tests without a model; live acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | @@ -62,7 +62,7 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17 | C Live order/cursors/empty/tenant checks; L DB tenant A/B | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | | 10 | beta.agents.sessions.delete | P: deletion only of a durably idle or failed Session without required actions or pending input; a busy root Turn or pending reservation gives 409 `conflict_error` with no change (subagent child Turns and pending Environment file writes are not checked); owner repeat returns the same 200; owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409; Z SES-29 `q5-delete-repeat` 200, SES-30 `q5b-delete-while-in-progress` 409, `q5-delete-never-existed` 404, `q5-delete-while-running` 200 right after an `events.create` 202 | D recorded real cleanup; C cleanup separately recorded; Z DB matrix D1–D4 with no-write digest, admission lock race and pinned-SDK script | Core returns 409 right after an `events.create` 202 because it admits Turns synchronously (official 200); input awaiting its Environment cannot be cancelled publicly and is unobserved officially; physical purge/retention may end repeat idempotency; caller compute ownership preserved | | 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission | S `second-turn-create.json`, `events-empty/null.json`; H second-input | C Live real continuation/no-op; T qualified message/result/cancel workflows | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools | -| 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress` | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage | Full SSE/Item variants/order (EVT-05..10 deferred); child deltas settle late; no replay guarantee or observer-disconnect proof for every state | +| 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress` | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage | Full SSE/Item variants/order (EVT-05..10 deferred); child Turns and Items are not streamed on the Session (U, SAT-09) and are read through the Subagent routes; no replay guarantee or observer-disconnect proof for every state | | 13 | beta.agents.sessions.turns.retrieve | P: persisted root Turn identity; malformed and child Turn IDs equal missing | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set; X SES-28 official `turn_` 404; U SAT-07 `C04` child Turn ID 404 | Recorded H/B scoped Turn recovery (B under the earlier mixed root/child contract); C history uses list; U DB child-ID 404 equal to missing, tenant B | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage; official 404 message text differs | | 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root Turns only; a child Turn cursor equals a missing one; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28; U SAT-07 `R06` root-only list beside a child Turn | C Live paging; H/B real child/root identity recorded under the earlier mixed contract; L DB tenant A/B; U DB root-only list and cursor | All interleavings, same-timestamp paging, interim/failed usage | | 15 | beta.agents.sessions.items.list | P: scoped root Items, full envelope; limit 0/above 100 clamp within the pinned 1–100 page | S `items-final/empty-page/limit-high.json`; H both directions; L SES-12/13/15/16/17 | C Live; H/T recorded content/coordination/result variants; L DB tenant A/B | Full Item union. Newer turn_id filter excluded from pin | diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index da3474c96..6c3a0dc1a 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -164,12 +164,13 @@ not established by these six resource reads. ## Subagent visibility — September 23, 2026 -The pin is unchanged: SDK 3.13.0, commit `d7c41ef`, `agents=v1`. This batch -starts from main `b77249c`. Its plan is +The pin is unchanged: SDK 3.13.0, commit `d7c41ef`, `agents=v1`. This batch is +based on main `73ecc152`. Its plan is `~/.parsar/remediation/20260923/subagent-visibility/PLAN.md`. The first owned official Subagent evidence is `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` -(SAT-01, 02, 07, 08, 09), with raw records under `official/`. It covers two owned +(SAT-01, 02, 07, 08, 09, with SAT-03 for the kept rejections), with raw records +under `official/`. It covers two owned Sessions and two child Turns, all deleted. | Row | Case | Core behavior | Evidence (finding: request ID) | @@ -178,7 +179,7 @@ Sessions and two child Turns, all deleted. | A2 | GET events and the creation stream | No `agent.session.turn.*` event for a child Turn, including its Item and content events. `agent.session.subagent.*` events and root coordination Items stay. The creation stream still ends on the root's settled idle | SAT-09: `req_e0f7fb0ca13f4eb98b4d677be046e1da`, `req_7a68fa8c18e344cfa0ed202df92a875e` (S1 20 and S2 43 frames, no child Turn or Item event) | | A3 | Child Turn `agent_id` | The Session's Agent ID; `subagent_id` unchanged. Nested children follow the same rule (not observed) | SAT-08: `req_85e7eb58da8e402c8103379ff5bb11d2`, `req_fc10f0d1a2e84bd086f006c01aa7ee54` | | A4 | Subagent list envelope | `object`, `data`, `first_id`, `last_id`, `has_more`; null IDs on an empty page | SAT-01: `req_089f86e8088d441380a22de2723e6179`, `req_5f79af4eaea44cb7ab4e92920e0f88c8` | -| A5 | `limit` 0 or above 100 | Subagent Item and Subagent Turn Item lists clamp to 1 and 100. The Subagent and Subagent Turn lists keep rejecting with `limit must be between 1 and 100` | SAT-02: `req_6179ae6c1d1640d899ee4798e7f9fa57`, `req_7436104afbae4e73a0eb43b00ec9e660`, `req_32899313414b4031849a22cd2927f0ad`; rejections `req_7df58d9579be4ee3ab7fdab55286aa05`, `req_b4321de4480c4a8e96b9ea285ff63a46`, `req_0f437ad4713d47a8af1f61a88636bf79`, `req_e9d476dd2a69472694cffc0851d0574c` | +| A5 | `limit` 0 or above 100 | Subagent Item and Subagent Turn Item lists clamp to 1 and 100. The Subagent and Subagent Turn lists keep rejecting with `limit must be between 1 and 100` | SAT-02: `req_6179ae6c1d1640d899ee4798e7f9fa57`, `req_7436104afbae4e73a0eb43b00ec9e660`, `req_32899313414b4031849a22cd2927f0ad`; SAT-03 rejections: `req_7df58d9579be4ee3ab7fdab55286aa05`, `req_b4321de4480c4a8e96b9ea285ff63a46`, `req_0f437ad4713d47a8af1f61a88636bf79`, `req_e9d476dd2a69472694cffc0851d0574c` | ### Decisions @@ -188,16 +189,22 @@ Sessions and two child Turns, all deleted. Creation-stream settlement reads the settled idle and the latest root Turn, and Session usage sums root Turns, so neither used child Turn events. The Core Web timeline had no Subagent view; it only showed child Turns as ordinary Turn rows - and now keeps its timeline root-only even against an earlier Core. Recovery + and now keeps its timeline root-only even against an earlier Core, hiding the + Items of Subagent Turns that Core listed or streamed. Recovery continues through Session, Turn and Item reads plus the Subagent routes. No internal signal had to be kept. - The scan recorded child Item events as already absent. They were not: every child Item recorded `turn.item.*` and content events with the child Turn ID. They are removed with the child Turn events, since the official parent stream carried neither. -- The official Subagent list error message and the child Turn 404 message differ - from Core's local ones (`No managed agent resource found: …`). Only the status, - error fields and "same as missing" behavior are aligned here. +- The official 404 message for a child Turn ID (`No managed agent resource found: + …`) and the Subagent 404 messages (SAT-06) differ from Core's local text. Only + the status, error fields and "same as missing" behavior are aligned here. +- Core may expose documented extensions beyond the official API. This batch + removes only the mixed Session Turn pages and child Session events, which were + not documented extensions. Root-only extensions such as + `agent.output.command_execution_output.delta` and the Web's handling of older + Core releases are unchanged or additive. Unchanged: Subagent retrieve fields and statuses, child history contents (SAT-12 remains unknown; Core keeps the child input Item), the hidden task text, the diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 7c2f40ce8..7ab4305c6 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -577,7 +577,10 @@ export type TurnStatus = "queued" | "in_progress" | "waiting" | "completed" | "f export interface AgentTurn { id: string; - /** The Session's Agent ID, for root and Subagent Turns alike. */ + /** + * The Session's Agent ID, for root and Subagent Turns alike. Older Core + * releases sent the Subagent's own ID here for Subagent Turns. + */ agent_id: string; /** Set on Subagent Turns, which are read through the Subagent routes. */ subagent_id?: string | null;