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
83 changes: 83 additions & 0 deletions packages/core/src/__tests__/subagent-session-parent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { SessionSummary, SubagentSessionParent } from '../session.js';
import { childSessionsForParent, isSubagentSessionParent } from '../session.js';

const relation: SubagentSessionParent = {
kind: 'subagent',
parentSessionId: 'parent-session',
spawnedBy: {
parentRunId: 'parent-run',
parentTurnId: 'parent-turn',
toolCallId: 'tool-call',
},
lifecycle: 'foreground',
};

describe('subagent session parent relation', () => {
test('strictly decodes standalone and swarm relations', () => {
assert.equal(isSubagentSessionParent(relation), true);
assert.equal(
isSubagentSessionParent({
...relation,
swarm: { swarmId: 'swarm-1', itemId: 'item-1' },
}),
true,
);
});

test('rejects malformed, unsupported, or extended persisted relations', () => {
assert.equal(isSubagentSessionParent({ ...relation, lifecycle: 'detached' }), false);
assert.equal(
isSubagentSessionParent({
...relation,
spawnedBy: { parentRunId: 'parent-run', parentTurnId: 'parent-turn' },
}),
false,
);
assert.equal(isSubagentSessionParent({ ...relation, parentSessionId: 'bad\nid' }), false);
assert.equal(isSubagentSessionParent({ ...relation, unexpected: true }), false);
});

test('derives reverse children without conflating ordinary branches', () => {
const childA = summary('child-a', { subagentParent: relation });
const branch = summary('branch', {
parentSessionId: 'parent-session',
branchOfTurnId: 'parent-turn',
});
const childB = summary('child-b', {
subagentParent: { ...relation, swarm: { swarmId: 'swarm-1', itemId: 'item-1' } },
});
const otherChild = summary('other-child', {
subagentParent: { ...relation, parentSessionId: 'other-parent' },
});

assert.deepEqual(
childSessionsForParent([childA, branch, childB, otherChild], 'parent-session').map(
(session) => session.id,
),
['child-a', 'child-b'],
);
});
});

function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
return {
id,
cwd: '/tmp',
name: id,
isFlagged: false,
isArchived: false,
labels: [],
hasUnread: false,
status: 'active',
backend: 'fake',
llmConnectionSlug: 'fake',
connectionLocked: false,
model: 'fake-model',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
...overrides,
};
}
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export type {
SessionChangedReason,
SessionStatus,
SessionBlockedReason,
SubagentSessionLifecycle,
SubagentSessionParent,
TurnRecord,
TurnStateMessage,
TurnStatus,
Expand All @@ -182,11 +184,14 @@ export type {
export {
SESSION_STATUSES,
SESSION_BLOCKED_REASONS,
SUBAGENT_SESSION_LIFECYCLES,
TURN_STATUSES,
childSessionsForParent,
STEP_LIMIT_NOTICE_TEXT,
deriveTurnRecords,
isSessionStatus,
isSessionBlockedReason,
isSubagentSessionParent,
isTurnStatus,
decodeStoredMessageForRead,
decodeStoredMessageForRecovery,
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/runtime-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
*/

import type { AttachmentRef, QuoteRef } from './events.js';
import type { BackendKind, SessionBlockedReason, SessionStatus } from './session.js';
import type {
BackendKind,
SessionBlockedReason,
SessionStatus,
SubagentSessionParent,
} from './session.js';
import type { PermissionMode } from './permission.js';
import type { ThinkingLevel } from './model-thinking.js';
import type { CollaborationMode } from './collaboration.js';
Expand Down Expand Up @@ -31,6 +36,7 @@ export interface CreateSessionInput {
blockedReason?: SessionBlockedReason;
parentSessionId?: string;
branchOfTurnId?: string;
subagentParent?: SubagentSessionParent;
revisionRootSessionId?: string;
revisionParentSessionId?: string;
revisionOfTurnId?: string;
Expand Down Expand Up @@ -114,4 +120,6 @@ export interface SessionListFilter {
isArchived?: boolean;
isFlagged?: boolean;
labelSlug?: string;
/** Return linked subagent sessions owned by this parent session. */
subagentParentSessionId?: string;
}
91 changes: 91 additions & 0 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,33 @@ export const TURN_STATUSES = ['running', 'completed', 'aborted', 'failed'] as co

export type TurnStatus = (typeof TURN_STATUSES)[number];

export const SUBAGENT_SESSION_LIFECYCLES = ['foreground'] as const;

export type SubagentSessionLifecycle = (typeof SUBAGENT_SESSION_LIFECYCLES)[number];

/**
* Durable control-plane lineage for a subagent session.
*
* The relation lives only on the child. Parents do not persist a reciprocal
* child-id array; reverse lookup is a read-model concern. Cross-session
* provenance deliberately stays out of AgentRun.parentRunId so runs inside the
* child session can retain normal session-inline history semantics.
*/
export interface SubagentSessionParent {
kind: 'subagent';
parentSessionId: string;
spawnedBy: {
parentRunId: string;
parentTurnId: string;
toolCallId: string;
};
swarm?: {
swarmId: string;
itemId: string;
};
lifecycle: SubagentSessionLifecycle;
}

export function isSessionStatus(value: unknown): value is SessionStatus {
return typeof value === 'string' && (SESSION_STATUSES as readonly string[]).includes(value);
}
Expand Down Expand Up @@ -103,8 +130,11 @@ export interface SessionHeader {
status: SessionStatus;
blockedReason?: SessionBlockedReason;
statusUpdatedAt?: number;
/** Ordinary branch lineage. Subagent lineage uses subagentParent instead. */
parentSessionId?: string;
branchOfTurnId?: string;
/** Immutable control-plane relation for a linked child-agent session. */
subagentParent?: SubagentSessionParent;
/** Stable root id for an edit-and-resend version family. */
revisionRootSessionId?: string;
/** Immediate previous version in the same conversation slot. */
Expand Down Expand Up @@ -156,6 +186,7 @@ export interface SessionSummary {
statusUpdatedAt?: number;
parentSessionId?: string;
branchOfTurnId?: string;
subagentParent?: SubagentSessionParent;
revisionRootSessionId?: string;
revisionParentSessionId?: string;
revisionOfTurnId?: string;
Expand All @@ -181,6 +212,66 @@ export interface SessionSummary {
orchestrationMode?: OrchestrationMode;
}

const SUBAGENT_SESSION_PARENT_SHAPE = defineObjectShape<SubagentSessionParent>()(
['kind', 'parentSessionId', 'spawnedBy', 'lifecycle'],
['swarm'],
);
const SUBAGENT_SESSION_SPAWN_SHAPE = defineObjectShape<SubagentSessionParent['spawnedBy']>()(
['parentRunId', 'parentTurnId', 'toolCallId'],
[],
);
const SUBAGENT_SESSION_SWARM_SHAPE = defineObjectShape<
NonNullable<SubagentSessionParent['swarm']>
>()(['swarmId', 'itemId'], []);
const SESSION_LINEAGE_ID_MAX_CHARS = 512;
const SESSION_LINEAGE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;

/** Strict decoder guard for the persisted child-session relation. */
export function isSubagentSessionParent(value: unknown): value is SubagentSessionParent {
if (
!isRecord(value) ||
!hasExactShape(value, SUBAGENT_SESSION_PARENT_SHAPE) ||
value.kind !== 'subagent' ||
!isSessionLineageId(value.parentSessionId) ||
value.lifecycle !== 'foreground' ||
!isRecord(value.spawnedBy) ||
!hasExactShape(value.spawnedBy, SUBAGENT_SESSION_SPAWN_SHAPE) ||
!isSessionLineageId(value.spawnedBy.parentRunId) ||
!isSessionLineageId(value.spawnedBy.parentTurnId) ||
!isSessionLineageId(value.spawnedBy.toolCallId)
) {
return false;
}
return (
value.swarm === undefined ||
(isRecord(value.swarm) &&
hasExactShape(value.swarm, SUBAGENT_SESSION_SWARM_SHAPE) &&
isSessionLineageId(value.swarm.swarmId) &&
isSessionLineageId(value.swarm.itemId))
);
}

/** Read-model projection; input order is preserved. */
export function childSessionsForParent(
sessions: readonly SessionSummary[],
parentSessionId: string,
): SessionSummary[] {
return sessions.filter(
(session) =>
isSubagentSessionParent(session.subagentParent) &&
session.subagentParent.parentSessionId === parentSessionId,
);
}

function isSessionLineageId(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= SESSION_LINEAGE_ID_MAX_CHARS &&
!SESSION_LINEAGE_CONTROL_CHARACTERS.test(value)
);
}

export type SessionChangedReason =
| 'created'
| 'updated'
Expand Down
40 changes: 40 additions & 0 deletions packages/runtime/src/__tests__/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ import {
} from '../expert-catalog.js';
import { AGENT_TEAM_CHILD_TOOL_NAMES } from '../agent-team-tool-names.js';

describe('SessionManager child-session read model', () => {
test('lists typed child sessions without treating branches as children', async () => {
const store = new MemorySessionStore();
const manager = new SessionManager({
store,
backends: new BackendRegistry(),
newId: nextId(),
now: nextNow(1),
});
const parent = await manager.createSession(makeInput({ name: 'Parent' }));
const child = await manager.createSession(
makeInput({
name: 'Child',
subagentParent: {
kind: 'subagent',
parentSessionId: parent.id,
spawnedBy: {
parentRunId: 'parent-run',
parentTurnId: 'parent-turn',
toolCallId: 'tool-call',
},
lifecycle: 'foreground',
},
}),
);
await manager.createSession(
makeInput({
name: 'Branch',
parentSessionId: parent.id,
branchOfTurnId: 'parent-turn',
}),
);

expect((await manager.listChildSessions(parent.id)).map((session) => session.id)).toEqual([
child.id,
]);
});
});

describe('SessionManager automatic titles', () => {
test('starts after user persistence and does not block the turn', async () => {
const store = new MemorySessionStore();
Expand Down Expand Up @@ -13019,6 +13058,7 @@ class MemorySessionStore implements SessionStore {
statusUpdatedAt: 1,
...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}),
...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}),
...(input.subagentParent ? { subagentParent: input.subagentParent } : {}),
...(input.revisionRootSessionId
? { revisionRootSessionId: input.revisionRootSessionId }
: {}),
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
import {
DEFAULT_SESSION_NAME,
DEEP_RESEARCH_SESSION_LABEL,
childSessionsForParent,
failureClassFromCompleteStopReason,
isDeepResearchSession,
isSessionInlineRun,
Expand Down Expand Up @@ -446,6 +447,11 @@ export class SessionManager {
return this.deps.store.list(filter);
}

async listChildSessions(parentSessionId: string): Promise<SessionSummary[]> {
const sessions = await this.deps.store.list({ subagentParentSessionId: parentSessionId });
return childSessionsForParent(sessions, parentSessionId);
}

/** Invalidate backend snapshots now, or immediately after active turns settle. */
async refreshIdleBackends(): Promise<void> {
const sessions = await this.deps.store.list();
Expand Down Expand Up @@ -2271,6 +2277,7 @@ export function headerToSummary(h: SessionHeader): SessionSummary {
...(h.statusUpdatedAt !== undefined ? { statusUpdatedAt: h.statusUpdatedAt } : {}),
...(h.parentSessionId ? { parentSessionId: h.parentSessionId } : {}),
...(h.branchOfTurnId ? { branchOfTurnId: h.branchOfTurnId } : {}),
...(h.subagentParent ? { subagentParent: h.subagentParent } : {}),
...(h.revisionRootSessionId ? { revisionRootSessionId: h.revisionRootSessionId } : {}),
...(h.revisionParentSessionId ? { revisionParentSessionId: h.revisionParentSessionId } : {}),
...(h.revisionOfTurnId ? { revisionOfTurnId: h.revisionOfTurnId } : {}),
Expand Down
Loading
Loading