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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -276,6 +279,12 @@ export function App() {
const [itemsSessionId, setItemsSessionId] = useState<string | null>(null);
const [turns, setTurns] = useState<AgentTurn[]>([]);
const [turnsSessionId, setTurnsSessionId] = useState<string | null>(null);
// Subagent Turn IDs an earlier Core listed or streamed; their Items stay hidden.
const [childTurnIds, setChildTurnIds] = useState<ReadonlyMap<string, ReadonlySet<string>>>(() => new Map());
const rootItems = useMemo(
() => rootSessionItems(items, selectedId ? childTurnIds.get(selectedId) : undefined),
[childTurnIds, items, selectedId],
);
const [turnCollectionLoad, setTurnCollectionLoad] = useState<SelectedSessionLoad>({
sessionId: null,
state: "idle",
Expand Down Expand Up @@ -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<string>();
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,
Expand Down Expand Up @@ -1038,6 +1051,7 @@ export function App() {
setRuntimeCollectionHasSnapshot(false);
setItems([]);
setTurns([]);
setChildTurnIds(new Map());
setEnvironmentObservations(new Map());
itemsSessionIdRef.current = null;
setItemsSessionId(null);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}
Expand Down
70 changes: 69 additions & 1 deletion apps/web/src/features/sessions/turns/turn-state.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -60,6 +63,19 @@ 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" });

const childTurnIds = new Set<string>();
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 () => {
const listTurns = vi.fn(async (_sessionId: string, options?: { after?: string }) => options?.after
? { data: [turn("turn-1", "in_progress"), turn("turn-2")], has_more: false }
Expand Down Expand Up @@ -118,6 +134,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",
Expand All @@ -134,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<string, ReadonlySet<string>> = 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);
});
});
59 changes: 57 additions & 2 deletions apps/web/src/features/sessions/turns/turn-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AgentCore,
AgentTurn,
SessionEvent,
SessionItem,
} from "@agents-core-web/agents-client";

const turnStatuses = new Set<AgentTurn["status"]>([
Expand Down Expand Up @@ -48,11 +49,24 @@ 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, 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.
* Subagent Turn IDs listed by an earlier Core are added to childTurnIds.
*/
export async function listAllTurns(
core: AgentCore,
sessionId: string,
signal?: AbortSignal,
childTurnIds?: Set<string>,
): Promise<AgentTurn[]> {
const turns: AgentTurn[] = [];
const indexes = new Map<string, number>();
Expand All @@ -68,6 +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)) {
childTurnIds?.add(value.id);
continue;
}
const index = indexes.get(value.id);
if (index === undefined) {
indexes.set(value.id, turns.length);
Expand Down Expand Up @@ -109,12 +127,49 @@ 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;
}

/**
* 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<string, ReadonlySet<string>>,
sessionId: string,
ids: Iterable<string>,
): ReadonlyMap<string, ReadonlySet<string>> {
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<string> | undefined): SessionItem[] {
if (!childTurnIds?.size) return items;
const visible = items.filter((item) => !childTurnIds.has(item.turn_id));
return visible.length === items.length ? items : visible;
}
10 changes: 5 additions & 5 deletions contracts/agents-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading