From 070b5fdb71168d42527a07d53de21af72638522b Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Wed, 23 Sep 2026 16:52:30 +0530 Subject: [PATCH 1/6] Add feature to share chat with anyone in the tenant --- .changeset/session-shared-flag.md | 6 ++++++ .../src/agent-session/models/SessionRecord.ts | 5 +++++ .../src/agent-session/schemas/session.ts | 1 + .../src/agent-session/store/ISessionStore.ts | 2 ++ .../store/InMemorySessionStore.ts | 4 ++++ .../tests/agent-session/sessions.test.ts | 2 ++ .../agent-session/store/storeContractSuite.ts | 9 ++++++++ packages/trueforge/src/apis/sessions.ts | 3 +++ .../20260923_000001_session_shared.ts | 17 +++++++++++++++ .../session-store/PostgresSessionStore.ts | 1 + .../session-store/queries/sessions.ts | 10 +++++++++ packages/trueforge/src/db/postgres/types.ts | 2 ++ .../20260923_000001_session_shared.ts | 21 +++++++++++++++++++ .../sqlite/session-store/queries/sessions.ts | 8 +++++++ packages/trueforge/src/db/sqlite/types.ts | 2 ++ .../trueforge/src/routes/sessionRoutes.ts | 7 ++++--- packages/trueforge/src/schemas/session.ts | 1 + 17 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 .changeset/session-shared-flag.md create mode 100644 packages/trueforge/src/db/postgres/migrations/20260923_000001_session_shared.ts create mode 100644 packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts diff --git a/.changeset/session-shared-flag.md b/.changeset/session-shared-flag.md new file mode 100644 index 000000000..29b12b3a9 --- /dev/null +++ b/.changeset/session-shared-flag.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge': patch +'@truefoundry/trueforge-core': patch +--- + +Let a session owner mark a session shared so any subject in the tenant can fetch it by id. diff --git a/packages/trueforge-core/src/agent-session/models/SessionRecord.ts b/packages/trueforge-core/src/agent-session/models/SessionRecord.ts index 80876cabc..6258a85b9 100644 --- a/packages/trueforge-core/src/agent-session/models/SessionRecord.ts +++ b/packages/trueforge-core/src/agent-session/models/SessionRecord.ts @@ -17,6 +17,11 @@ export interface SessionRecord> { * update_session_title_if_not_exist (first write wins; caller derives). */ title: string | null; + /** + * When true, any subject in the tenant may GET this session. + * Mutations stay owner-only. List filtering ignores this flag. + */ + shared: boolean; /** * Optional caller-supplied key, unique within a tenant when set. * Null means the session has no external id. diff --git a/packages/trueforge-core/src/agent-session/schemas/session.ts b/packages/trueforge-core/src/agent-session/schemas/session.ts index 914287fa8..2db8371bf 100644 --- a/packages/trueforge-core/src/agent-session/schemas/session.ts +++ b/packages/trueforge-core/src/agent-session/schemas/session.ts @@ -105,6 +105,7 @@ export const SessionSchema = z id: z.string().describe('Unique session id.'), agent: SessionAgentSchema, title: z.string().nullable().describe('Optional human-readable title; null until set.'), + shared: z.boolean().describe('When true, any subject in the tenant may fetch this session by id.'), created_by_subject: CreatedBySubjectSchema, created_at: z.string().describe('ISO 8601 creation timestamp.'), updated_at: z.string().describe('ISO 8601 last-update timestamp.'), diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index e423cfff6..030171946 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -37,6 +37,8 @@ export type UpdateSessionInput['agent'], { type: 'inline' }> | undefined; title: SessionRecord['title'] | undefined; metadata: SessionRecord['metadata'] | undefined; + /** When omitted, the stored flag is left unchanged. */ + shared: SessionRecord['shared'] | undefined; }; export interface GetSessionInput { diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index 88ee5b224..8369aec9a 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -203,6 +203,7 @@ export class InMemorySessionStore< created_by_subject: input.created_by_subject, agent: deepCopy(input.agent), title: null, + shared: false, last_turn_id: null, external_id: externalId, source: input.source !== null ? deepCopy(input.source) : null, @@ -285,6 +286,9 @@ export class InMemorySessionStore< if (input.metadata !== undefined) { stored.record.metadata = deepCopy(input.metadata); } + if (input.shared !== undefined) { + stored.record.shared = input.shared; + } const now = Date.now(); stored.record.updated_at = new Date(now); stored.record.last_activity_timestamp_ms = now; diff --git a/packages/trueforge-core/tests/agent-session/sessions.test.ts b/packages/trueforge-core/tests/agent-session/sessions.test.ts index 34a060cf3..f1daa0619 100644 --- a/packages/trueforge-core/tests/agent-session/sessions.test.ts +++ b/packages/trueforge-core/tests/agent-session/sessions.test.ts @@ -28,6 +28,7 @@ describe('Sessions / SessionHandle / TurnHandle (storage + createTurn)', () => { agent: undefined, title: undefined, metadata: { env: 'prod' }, + shared: undefined, }); const afterReplace = await sessions.get({ tenant_id: tenant, session_id: 's-meta' }); expect(afterReplace?.metadata).toEqual({ env: 'prod' }); @@ -38,6 +39,7 @@ describe('Sessions / SessionHandle / TurnHandle (storage + createTurn)', () => { agent: undefined, title: 't', metadata: undefined, + shared: undefined, }); const afterOmit = await sessions.get({ tenant_id: tenant, session_id: 's-meta' }); expect(afterOmit?.record.title).toBe('t'); diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 36a8f0eba..7e7fe1415 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -282,6 +282,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: { type: 'inline', spec: makeAgentSpec({ instructions: 'nope' }) }, title: undefined, metadata: undefined, + shared: undefined, }), ).rejects.toBeInstanceOf(SessionStoreInvariantError); }); @@ -307,6 +308,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: { type: 'inline', spec: nextSpec }, title: 'Hello', metadata: undefined, + shared: undefined, }); const after = await store.getSession({ tenant_id: tenant, session_id: sessionId }); expect(mustGet(after).agent).toMatchObject({ @@ -353,6 +355,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: undefined, metadata: { b: '2' }, + shared: undefined, }); expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).metadata).toEqual({ b: '2', @@ -364,6 +367,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: 'keep-meta', metadata: undefined, + shared: undefined, }); const afterOmit = mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })); expect(afterOmit.title).toBe('keep-meta'); @@ -375,6 +379,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: undefined, metadata: {}, + shared: undefined, }); expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).metadata).toEqual({}); }); @@ -648,6 +653,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: 'new-title', metadata: undefined, + shared: undefined, }), ).rejects.toBeInstanceOf(SessionNotFoundError); await expect(store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-2' }))).rejects.toBeInstanceOf( @@ -802,6 +808,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: 'new-title', metadata: undefined, + shared: undefined, }), ).rejects.toBeInstanceOf(SessionNotFoundError); await expect( @@ -989,6 +996,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: 'bumped', metadata: undefined, + shared: undefined, }); const listArgs = { @@ -2647,6 +2655,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: jsonLooking, metadata: undefined, + shared: undefined, }); await store.createTurn( makeCreateTurnInput({ diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index cbdf0a944..6358c33b0 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -63,6 +63,7 @@ export function toWireSession(record: SessionRecord): Session { id: record.session_id, agent: record.agent, title: record.title, + shared: record.shared, created_by_subject: record.created_by_subject, created_at: record.created_at.toISOString(), updated_at: record.updated_at.toISOString(), @@ -389,6 +390,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } if ( + !record.shared && !(await canReadAgentBoundResource({ store: deps.resolveAgentStore(c), context: requestContext, @@ -467,6 +469,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { agent: body.agent === undefined ? undefined : { type: 'inline', spec: body.agent.spec }, title: body.title, metadata: body.metadata, + shared: body.shared, }); } catch (error) { if (error instanceof SessionStoreNotFoundError) { diff --git a/packages/trueforge/src/db/postgres/migrations/20260923_000001_session_shared.ts b/packages/trueforge/src/db/postgres/migrations/20260923_000001_session_shared.ts new file mode 100644 index 000000000..2b38815e4 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260923_000001_session_shared.ts @@ -0,0 +1,17 @@ +import { sql, type Kysely } from 'kysely'; + +/** Tenant-visible share flag. Existing sessions stay private. */ +export async function up(db: Kysely): Promise { + await sql` + SET LOCAL lock_timeout = '5s'; + ALTER TABLE session + ADD COLUMN shared boolean NOT NULL DEFAULT false; + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + SET LOCAL lock_timeout = '5s'; + ALTER TABLE session DROP COLUMN IF EXISTS shared; + `.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts index fff1f5ca6..8be8112f7 100644 --- a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts +++ b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts @@ -343,6 +343,7 @@ export class PostgresSessionStore implements ISessionStore(resolvedAgentSpec) : null, title: session.title, + shared: false, last_turn_id: session.last_turn_id, custom: session.custom !== null ? json(session.custom) : null, metadata: json(metadata), diff --git a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts index 6b45219ce..542fe7c59 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts @@ -65,6 +65,7 @@ function mapRowToSessionRecord(row: { agent_name: string | null; agent_spec: AgentSpec | null; title: string | null; + shared: boolean; last_turn_id: string | null; external_id: string | null; custom: Record | null; @@ -86,6 +87,7 @@ function mapRowToSessionRecord(row: { agent_spec: row.agent_spec, }), title: row.title, + shared: row.shared, last_turn_id: row.last_turn_id, external_id: row.external_id, custom: parseSessionCustom(row.custom), @@ -113,6 +115,7 @@ export async function createSession(db: Kysely, input: CreateSessionIn agent_name: columns.agent_name, agent_spec: columns.agent_spec !== null ? json(columns.agent_spec) : null, title: null, + shared: false, custom: input.custom !== null ? json(input.custom) : null, metadata: json(input.metadata), external_id: input.external_id, @@ -198,6 +201,7 @@ export async function updateSession(db: Kysely, input: UpdateSessionIn const agent = input.agent; const title = input.title; const metadata = input.metadata; + const shared = input.shared; if (agent !== undefined) { const existing = await getSession(db, { tenant_id: input.tenant_id, session_id: input.session_id }); @@ -233,6 +237,12 @@ export async function updateSession(db: Kysely, input: UpdateSessionIn } return qb.set({ metadata: json(metadata) }); }) + .$if(shared !== undefined, qb => { + if (shared === undefined) { + return qb; + } + return qb.set({ shared }); + }) .where('tenant_id', '=', input.tenant_id) .where('session_id', '=', input.session_id) .executeTakeFirst(); diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index b188b353f..1dbeabf1a 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -87,6 +87,8 @@ export interface SessionTable { * (COALESCE) targets it directly */ title: string | null; + /** When true, any subject in the tenant may GET this session. */ + shared: boolean; /** * top: HOT — bumped once per createTurn under the session lock; * tiny fixed-width column keeps the bump a cheap HOT update diff --git a/packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts b/packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts new file mode 100644 index 000000000..8baed8bb8 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts @@ -0,0 +1,21 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Tenant-visible share flag. Existing sessions stay private. + * Mirrors db/postgres/migrations/20260923_000001_session_shared.ts. + * Kysely does not wrap SQLite migrations — keep schema changes in a transaction. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql` + ALTER TABLE session + ADD COLUMN shared INTEGER NOT NULL DEFAULT 0 + `.execute(trx); + }); +} + +export async function down(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`ALTER TABLE session DROP COLUMN shared`.execute(trx); + }); +} diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts b/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts index 25ae08540..cd0373e54 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts @@ -64,6 +64,7 @@ function mapRowToSessionRecord(row: { agent_name: string | null; agent_spec: AgentSpec | null; title: string | null; + shared: number; last_turn_id: string | null; external_id: string | null; custom: Record | null; @@ -85,6 +86,7 @@ function mapRowToSessionRecord(row: { agent_spec: row.agent_spec, }), title: row.title, + shared: row.shared !== 0, last_turn_id: row.last_turn_id, external_id: row.external_id, custom: parseSessionCustom(row.custom), @@ -106,6 +108,7 @@ function sessionSelectColumns() { 'agent_name' as const, jsonText(sql.ref('agent_spec')).as('agent_spec'), 'title' as const, + 'shared' as const, 'last_turn_id' as const, 'external_id' as const, jsonText | null>(sql.ref('custom')).as('custom'), @@ -133,6 +136,7 @@ export async function createSession(db: Kysely, input: CreateSessionIn agent_name: columns.agent_name, agent_spec: columns.agent_spec !== null ? jsonbBind(columns.agent_spec) : null, title: null, + shared: 0, custom: input.custom !== null ? jsonbBind(input.custom) : null, metadata: jsonbBind(input.metadata), external_id: input.external_id, @@ -228,6 +232,7 @@ export async function updateSession(db: Kysely, input: UpdateSessionIn const agent = input.agent; const title = input.title; const metadata = input.metadata; + const shared = input.shared; if (agent !== undefined) { const existing = await getSession(db, { tenant_id: input.tenant_id, session_id: input.session_id }); @@ -257,6 +262,9 @@ export async function updateSession(db: Kysely, input: UpdateSessionIn if (metadata !== undefined) { qb = qb.set({ metadata: jsonbBind(metadata) }); } + if (shared !== undefined) { + qb = qb.set({ shared: shared ? 1 : 0 }); + } const result = await qb.executeTakeFirst(); diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 2ed2bc59c..70ca6f716 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -78,6 +78,8 @@ export interface SessionTable { /** Inline spec binding; XOR with `agent_id`. */ agent_spec: JsonbColumn | null; title: string | null; + /** 0/1. When 1, any subject in the tenant may GET this session. */ + shared: number; last_turn_id: string | null; /** Optional unique key within `tenant_id` when set. */ external_id: string | null; diff --git a/packages/trueforge/src/routes/sessionRoutes.ts b/packages/trueforge/src/routes/sessionRoutes.ts index 331b0e8d1..3031258e4 100644 --- a/packages/trueforge/src/routes/sessionRoutes.ts +++ b/packages/trueforge/src/routes/sessionRoutes.ts @@ -112,7 +112,8 @@ export const getSessionRoute = createRoute({ path: '/{session_id}', tags: [OpenApiTag.AGENT_SESSIONS], summary: 'Get a session', - description: 'Fetch a session by ID. Only the session creator may fetch it.', + description: + 'Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.', 'x-fern-sdk-group-name': ['sessions'], 'x-fern-sdk-method-name': 'get', request: { @@ -125,7 +126,7 @@ export const getSessionRoute = createRoute({ }, 403: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Caller is not the session creator.', + description: 'Caller cannot read this session.', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -163,7 +164,7 @@ export const updateSessionRoute = createRoute({ tags: [OpenApiTag.AGENT_SESSIONS], summary: 'Update a session', description: - 'Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.', + 'Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.', 'x-fern-sdk-group-name': ['sessions'], 'x-fern-sdk-method-name': 'update', request: { diff --git a/packages/trueforge/src/schemas/session.ts b/packages/trueforge/src/schemas/session.ts index 2dc7b4530..a711ebbf3 100644 --- a/packages/trueforge/src/schemas/session.ts +++ b/packages/trueforge/src/schemas/session.ts @@ -68,6 +68,7 @@ export const UpdateSessionRequestSchema = z agent: SessionAgentSpecBodySchema.optional(), title: SessionTitleSchema.optional(), metadata: SessionMetadataSchema.optional(), + shared: z.boolean().optional().describe('When true, any subject in the tenant may fetch this session by id.'), }) .strict() .openapi('UpdateSessionRequest'); From 54bec455be0a911728b2f1c3ab9c8fe4924ed681 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Wed, 23 Sep 2026 11:26:35 +0000 Subject: [PATCH 2/6] Regenerate OpenAPI document and SDKs --- ...60923112635-regenerate-sdk-from-openapi.md | 5 ++++ .github/fern/openapi/openapi.json | 15 ++++++++-- docs/openapi.json | 15 ++++++++-- packages/trueforge-sdk/reference.md | 4 +-- .../api/resources/sessions/client/Client.ts | 4 +-- .../client/requests/UpdateSessionRequest.ts | 2 ++ .../trueforge-sdk/src/api/types/Session.ts | 2 ++ .../client/requests/UpdateSessionRequest.ts | 2 ++ .../src/serialization/types/Session.ts | 2 ++ .../tests/wire/internal/sessions.test.ts | 2 ++ .../trueforge-sdk/tests/wire/sessions.test.ts | 8 +++++ python/trueforge_sdk/reference.md | 12 ++++++-- .../src/trueforge_sdk/sessions/client.py | 30 +++++++++++++++---- .../src/trueforge_sdk/sessions/raw_client.py | 18 ++++++++--- .../src/trueforge_sdk/types/session.py | 5 ++++ 15 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 .changeset/20260923112635-regenerate-sdk-from-openapi.md diff --git a/.changeset/20260923112635-regenerate-sdk-from-openapi.md b/.changeset/20260923112635-regenerate-sdk-from-openapi.md new file mode 100644 index 000000000..efd8ff00f --- /dev/null +++ b/.changeset/20260923112635-regenerate-sdk-from-openapi.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-sdk": patch +--- + +Regenerate SDK from updated OpenAPI spec. diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 76efd9628..b7813614d 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -3975,6 +3975,10 @@ "metrics": { "$ref": "#/components/schemas/SessionMetrics" }, + "shared": { + "description": "When true, any subject in the tenant may fetch this session by id.", + "type": "boolean" + }, "source": { "$ref": "#/components/schemas/SessionSource" }, @@ -3994,6 +3998,7 @@ "id", "agent", "title", + "shared", "created_by_subject", "created_at", "updated_at", @@ -5684,6 +5689,10 @@ "metadata": { "$ref": "#/components/schemas/SessionMetadata" }, + "shared": { + "description": "When true, any subject in the tenant may fetch this session by id.", + "type": "boolean" + }, "title": { "description": "Human-readable session title.", "maxLength": 50, @@ -8472,7 +8481,7 @@ "x-fern-sdk-method-name": "delete" }, "get": { - "description": "Fetch a session by ID. Only the session creator may fetch it.", + "description": "Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -8506,7 +8515,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -8529,7 +8538,7 @@ "x-fern-sdk-method-name": "get" }, "patch": { - "description": "Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.", + "description": "Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.", "parameters": [ { "description": "Session identifier.", diff --git a/docs/openapi.json b/docs/openapi.json index 76efd9628..b7813614d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3975,6 +3975,10 @@ "metrics": { "$ref": "#/components/schemas/SessionMetrics" }, + "shared": { + "description": "When true, any subject in the tenant may fetch this session by id.", + "type": "boolean" + }, "source": { "$ref": "#/components/schemas/SessionSource" }, @@ -3994,6 +3998,7 @@ "id", "agent", "title", + "shared", "created_by_subject", "created_at", "updated_at", @@ -5684,6 +5689,10 @@ "metadata": { "$ref": "#/components/schemas/SessionMetadata" }, + "shared": { + "description": "When true, any subject in the tenant may fetch this session by id.", + "type": "boolean" + }, "title": { "description": "Human-readable session title.", "maxLength": 50, @@ -8472,7 +8481,7 @@ "x-fern-sdk-method-name": "delete" }, "get": { - "description": "Fetch a session by ID. Only the session creator may fetch it.", + "description": "Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -8506,7 +8515,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -8529,7 +8538,7 @@ "x-fern-sdk-method-name": "get" }, "patch": { - "description": "Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.", + "description": "Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.", "parameters": [ { "description": "Session identifier.", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index d4d1e893f..aa69f497b 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -1552,7 +1552,7 @@ await client.sessions.create({
-Fetch a session by ID. Only the session creator may fetch it. +Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -1678,7 +1678,7 @@ await client.sessions.delete("session_id");
-Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. +Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.
diff --git a/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts b/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts index 422c38ef2..ff22ba92d 100644 --- a/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts @@ -282,7 +282,7 @@ export class SessionsClient { } /** - * Fetch a session by ID. Only the session creator may fetch it. + * Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. * * @param {string} session_id - Session identifier. * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. @@ -453,7 +453,7 @@ export class SessionsClient { } /** - * Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. + * Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. * * @param {string} session_id - Session identifier. * @param {TrueForge.UpdateSessionRequest} request diff --git a/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts b/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts index 247dc69bc..23fbd0d48 100644 --- a/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts +++ b/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts @@ -9,6 +9,8 @@ import type * as TrueForge from "../../../../index.js"; export interface UpdateSessionRequest { agent?: TrueForge.SessionAgentSpecBody; metadata?: TrueForge.SessionMetadata; + /** When true, any subject in the tenant may fetch this session by id. */ + shared?: boolean; /** Human-readable session title. */ title?: string; } diff --git a/packages/trueforge-sdk/src/api/types/Session.ts b/packages/trueforge-sdk/src/api/types/Session.ts index eaf080418..2818a4bb6 100644 --- a/packages/trueforge-sdk/src/api/types/Session.ts +++ b/packages/trueforge-sdk/src/api/types/Session.ts @@ -11,6 +11,8 @@ export interface Session { id: string; metadata: TrueForge.SessionMetadata; metrics: TrueForge.SessionMetrics; + /** When true, any subject in the tenant may fetch this session by id. */ + shared: boolean; source: TrueForge.SessionSource | null; /** Optional human-readable title; null until set. */ title: string | null; diff --git a/packages/trueforge-sdk/src/serialization/resources/sessions/client/requests/UpdateSessionRequest.ts b/packages/trueforge-sdk/src/serialization/resources/sessions/client/requests/UpdateSessionRequest.ts index 9bd3e18ad..403468ef7 100644 --- a/packages/trueforge-sdk/src/serialization/resources/sessions/client/requests/UpdateSessionRequest.ts +++ b/packages/trueforge-sdk/src/serialization/resources/sessions/client/requests/UpdateSessionRequest.ts @@ -12,6 +12,7 @@ export const UpdateSessionRequest: core.serialization.Schema< > = core.serialization.object({ agent: SessionAgentSpecBody.optional(), metadata: SessionMetadata.optional(), + shared: core.serialization.boolean().optional(), title: core.serialization.string().optional(), }); @@ -19,6 +20,7 @@ export declare namespace UpdateSessionRequest { export interface Raw { agent?: SessionAgentSpecBody.Raw | null; metadata?: SessionMetadata.Raw | null; + shared?: boolean | null; title?: string | null; } } diff --git a/packages/trueforge-sdk/src/serialization/types/Session.ts b/packages/trueforge-sdk/src/serialization/types/Session.ts index 14bc19586..6be95d0e0 100644 --- a/packages/trueforge-sdk/src/serialization/types/Session.ts +++ b/packages/trueforge-sdk/src/serialization/types/Session.ts @@ -17,6 +17,7 @@ export const Session: core.serialization.ObjectSchema { id: "id", metadata: { key: "value" }, metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, + shared: true, source: { id: "id", run_id: "run_id", type: "schedule" }, title: "title", updated_at: "updated_at", @@ -67,6 +68,7 @@ describe("SessionsClient", () => { totalDurationMs: 1, totalTurns: 1, }, + shared: true, source: { id: "id", runId: "run_id", diff --git a/packages/trueforge-sdk/tests/wire/sessions.test.ts b/packages/trueforge-sdk/tests/wire/sessions.test.ts index 0eb6c2dc9..aceaf8903 100644 --- a/packages/trueforge-sdk/tests/wire/sessions.test.ts +++ b/packages/trueforge-sdk/tests/wire/sessions.test.ts @@ -22,6 +22,7 @@ describe("SessionsClient", () => { id: "id", metadata: { key: "value" }, metrics: { total_duration_ms: 1, total_turns: 1 }, + shared: true, source: { id: "id", run_id: "run_id", type: "schedule" }, title: "title", updated_at: "updated_at", @@ -63,6 +64,7 @@ describe("SessionsClient", () => { totalDurationMs: 1, totalTurns: 1, }, + shared: true, source: { id: "id", runId: "run_id", @@ -115,6 +117,7 @@ describe("SessionsClient", () => { id: "id", metadata: { key: "value" }, metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, + shared: true, source: { id: "id", run_id: "run_id", type: "schedule" }, title: "title", updated_at: "updated_at", @@ -160,6 +163,7 @@ describe("SessionsClient", () => { totalDurationMs: 1, totalTurns: 1, }, + shared: true, source: { id: "id", runId: "run_id", @@ -259,6 +263,7 @@ describe("SessionsClient", () => { id: "id", metadata: { key: "value" }, metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, + shared: true, source: { id: "id", run_id: "run_id", type: "schedule" }, title: "title", updated_at: "updated_at", @@ -299,6 +304,7 @@ describe("SessionsClient", () => { totalDurationMs: 1, totalTurns: 1, }, + shared: true, source: { id: "id", runId: "run_id", @@ -393,6 +399,7 @@ describe("SessionsClient", () => { id: "id", metadata: { key: "value" }, metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, + shared: true, source: { id: "id", run_id: "run_id", type: "schedule" }, title: "title", updated_at: "updated_at", @@ -434,6 +441,7 @@ describe("SessionsClient", () => { totalDurationMs: 1, totalTurns: 1, }, + shared: true, source: { id: "id", runId: "run_id", diff --git a/python/trueforge_sdk/reference.md b/python/trueforge_sdk/reference.md index e6f916a46..d308f86c1 100644 --- a/python/trueforge_sdk/reference.md +++ b/python/trueforge_sdk/reference.md @@ -1871,7 +1871,7 @@ client.sessions.create(
-Fetch a session by ID. Only the session creator may fetch it. +Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -2015,7 +2015,7 @@ client.sessions.delete(
-Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. +Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it.
@@ -2079,6 +2079,14 @@ client.sessions.update(
+**shared:** `typing.Optional[bool]` — When true, any subject in the tenant may fetch this session by id. + +
+
+ +
+
+ **title:** `typing.Optional[str]` — Human-readable session title.
diff --git a/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py b/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py index ce744a779..1b20bcbfd 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py @@ -178,7 +178,7 @@ def create( def get(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> GetSessionResponse: """ - Fetch a session by ID. Only the session creator may fetch it. + Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -245,11 +245,12 @@ def update( session_id: str, agent: typing.Optional[SessionAgentSpecBody] = OMIT, metadata: typing.Optional[SessionMetadata] = OMIT, + shared: typing.Optional[bool] = OMIT, title: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> GetSessionResponse: """ - Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. + Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. Parameters ---------- @@ -260,6 +261,9 @@ def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + When true, any subject in the tenant may fetch this session by id. + title : typing.Optional[str] Human-readable session title. @@ -284,7 +288,12 @@ def update( ) """ _response = self._raw_client.update( - session_id=session_id, agent=agent, metadata=metadata, title=title, request_options=request_options + session_id=session_id, + agent=agent, + metadata=metadata, + shared=shared, + title=title, + request_options=request_options, ) return _response.data @@ -907,7 +916,7 @@ async def get( self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None ) -> GetSessionResponse: """ - Fetch a session by ID. Only the session creator may fetch it. + Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -990,11 +999,12 @@ async def update( session_id: str, agent: typing.Optional[SessionAgentSpecBody] = OMIT, metadata: typing.Optional[SessionMetadata] = OMIT, + shared: typing.Optional[bool] = OMIT, title: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> GetSessionResponse: """ - Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. + Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. Parameters ---------- @@ -1005,6 +1015,9 @@ async def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + When true, any subject in the tenant may fetch this session by id. + title : typing.Optional[str] Human-readable session title. @@ -1037,7 +1050,12 @@ async def main() -> None: asyncio.run(main()) """ _response = await self._raw_client.update( - session_id=session_id, agent=agent, metadata=metadata, title=title, request_options=request_options + session_id=session_id, + agent=agent, + metadata=metadata, + shared=shared, + title=title, + request_options=request_options, ) return _response.data diff --git a/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py b/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py index 1a8065d01..9e2255208 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py @@ -278,7 +278,7 @@ def get( self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None ) -> HttpResponse[GetSessionResponse]: """ - Fetch a session by ID. Only the session creator may fetch it. + Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -389,11 +389,12 @@ def update( session_id: str, agent: typing.Optional[SessionAgentSpecBody] = OMIT, metadata: typing.Optional[SessionMetadata] = OMIT, + shared: typing.Optional[bool] = OMIT, title: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[GetSessionResponse]: """ - Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. + Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. Parameters ---------- @@ -404,6 +405,9 @@ def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + When true, any subject in the tenant may fetch this session by id. + title : typing.Optional[str] Human-readable session title. @@ -423,6 +427,7 @@ def update( object_=agent, annotation=SessionAgentSpecBody, direction="write" ), "metadata": metadata, + "shared": shared, "title": title, }, headers={ @@ -1801,7 +1806,7 @@ async def get( self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[GetSessionResponse]: """ - Fetch a session by ID. Only the session creator may fetch it. + Fetch a session by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1914,11 +1919,12 @@ async def update( session_id: str, agent: typing.Optional[SessionAgentSpecBody] = OMIT, metadata: typing.Optional[SessionMetadata] = OMIT, + shared: typing.Optional[bool] = OMIT, title: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[GetSessionResponse]: """ - Update a session: optional `title`, `metadata`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. + Update a session: optional `title`, `metadata`, `shared`, and (inline sessions only) `agent` as `{ spec: AgentSpec }`. Named sessions reject agent updates. An empty body is a valid no-op that refreshes `updated_at`. Only the session creator may update it. Parameters ---------- @@ -1929,6 +1935,9 @@ async def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + When true, any subject in the tenant may fetch this session by id. + title : typing.Optional[str] Human-readable session title. @@ -1948,6 +1957,7 @@ async def update( object_=agent, annotation=SessionAgentSpecBody, direction="write" ), "metadata": metadata, + "shared": shared, "title": title, }, headers={ diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/session.py b/python/trueforge_sdk/src/trueforge_sdk/types/session.py index 69b629dc6..f2700b292 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/types/session.py +++ b/python/trueforge_sdk/src/trueforge_sdk/types/session.py @@ -27,6 +27,11 @@ class Session(UncheckedBaseModel): metadata: SessionMetadata metrics: SessionMetrics + shared: bool = pydantic.Field() + """ + When true, any subject in the tenant may fetch this session by id. + """ + source: typing.Optional[SessionSource] = None title: typing.Optional[str] = pydantic.Field(default=None) """ From 0948c05e0cbe4e1231796a7bafa86d7f7f0ee28d Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Wed, 23 Sep 2026 17:09:38 +0530 Subject: [PATCH 3/6] Allow listing turns and events too --- .changeset/session-shared-flag.md | 2 +- .../src/agent-session/schemas/session.ts | 4 +- packages/trueforge/src/apis/agentAccess.ts | 19 +++++ packages/trueforge/src/apis/sessions.ts | 68 +++++++++-------- packages/trueforge/src/apis/turns.ts | 73 +++++++++---------- .../trueforge/src/routes/sessionRoutes.ts | 4 +- packages/trueforge/src/routes/turnRoutes.ts | 13 ++-- packages/trueforge/src/schemas/session.ts | 5 +- .../trueforge/tests/unit/apis/turns.test.ts | 71 ++++++++++++++++++ 9 files changed, 176 insertions(+), 83 deletions(-) diff --git a/.changeset/session-shared-flag.md b/.changeset/session-shared-flag.md index 29b12b3a9..428bb44e8 100644 --- a/.changeset/session-shared-flag.md +++ b/.changeset/session-shared-flag.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-core': patch --- -Let a session owner mark a session shared so any subject in the tenant can fetch it by id. +Let a session owner mark a session shared so any subject in the tenant can read it by id, including turns and events (not subscribe or sandbox downloads). diff --git a/packages/trueforge-core/src/agent-session/schemas/session.ts b/packages/trueforge-core/src/agent-session/schemas/session.ts index 2db8371bf..94ac131f8 100644 --- a/packages/trueforge-core/src/agent-session/schemas/session.ts +++ b/packages/trueforge-core/src/agent-session/schemas/session.ts @@ -105,7 +105,9 @@ export const SessionSchema = z id: z.string().describe('Unique session id.'), agent: SessionAgentSchema, title: z.string().nullable().describe('Optional human-readable title; null until set.'), - shared: z.boolean().describe('When true, any subject in the tenant may fetch this session by id.'), + shared: z + .boolean() + .describe('When true, any subject in the tenant may read this session and its turns/events by id.'), created_by_subject: CreatedBySubjectSchema, created_at: z.string().describe('ISO 8601 creation timestamp.'), updated_at: z.string().describe('ISO 8601 last-update timestamp.'), diff --git a/packages/trueforge/src/apis/agentAccess.ts b/packages/trueforge/src/apis/agentAccess.ts index 84e7adba2..c5aa21194 100644 --- a/packages/trueforge/src/apis/agentAccess.ts +++ b/packages/trueforge/src/apis/agentAccess.ts @@ -96,3 +96,22 @@ export async function canReadAgentBoundResource(input: { const managedAgentIds = await resolveManagedAgentIds({ store, context, authorizer }); return managedAgentIds.includes(agent_id); } + +/** + * Session conversation reads (get session, list events/turns). Shared sessions + * are readable by any tenant member; otherwise same as {@link canReadAgentBoundResource}. + * Mutating ops, subscribe, and sandbox downloads stay creator-only on purpose. + */ +export async function canReadSession(input: { + shared: boolean; + store: IAgentStore; + context: RequestContext; + authorizer: Authorizer; + created_by_subject_id: string; + agent_id: string | undefined; +}): Promise { + if (input.shared) { + return true; + } + return canReadAgentBoundResource(input); +} diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index 6358c33b0..c865e0e7f 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -44,7 +44,7 @@ import { validateAgentSpec } from '../runtime/sessionResources'; import { honoQueriesToRecord } from '../schemas/deepObjectQuery'; import { isSessionAgentNameRef, parseListSessionsQuery, type Session } from '../schemas/session'; import { newId } from '../utils/id'; -import { agentIfAccessible, canReadAgentBoundResource, resolveManagedAgentIds } from './agentAccess'; +import { agentIfAccessible, canReadAgentBoundResource, canReadSession, resolveManagedAgentIds } from './agentAccess'; import type { ResolveSkillStore } from './skills'; /** Request-reply path a replica serves to cancel a turn it owns. */ @@ -256,15 +256,14 @@ function createGetOrCreateSessionByExternalIdHandler( external_id: body.external_id, }); if (existing !== undefined) { - if ( - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: existing.record.agent.type === 'reference' ? existing.record.agent.id : undefined, - created_by_subject_id: existing.record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadAgentBoundResource({ + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: existing.record.agent.type === 'reference' ? existing.record.agent.id : undefined, + created_by_subject_id: existing.record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } return c.json({ data: toWireSession(existing.record) }, 200); @@ -305,17 +304,17 @@ function createGetOrCreateSessionByExternalIdHandler( agent, source: body.source ?? null, }); - if ( - !created && - !(await canReadAgentBoundResource({ + if (!created) { + const allowed = await canReadAgentBoundResource({ store: deps.resolveAgentStore(c), context: requestContext, authorizer: deps.authorizer, agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, created_by_subject_id: session.record.created_by_subject.subject_id, - })) - ) { - return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); + }); + if (!allowed) { + return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); + } } return c.json({ data: toWireSession(session.record) }, created ? 201 : 200); }; @@ -389,16 +388,15 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { if (!record) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !record.shared && - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: record.agent.type === 'reference' ? record.agent.id : undefined, - created_by_subject_id: record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadSession({ + shared: record.shared, + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: record.agent.type === 'reference' ? record.agent.id : undefined, + created_by_subject_id: record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } return c.json({ data: toWireSession(record) }, 200); @@ -564,15 +562,15 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, - created_by_subject_id: session.record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadSession({ + shared: session.record.shared, + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, + created_by_subject_id: session.record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } try { diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 4ffc8994c..fd54a3d27 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -65,7 +65,7 @@ import { checkSnapshotStatus } from '../sandbox/providerUtils'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; -import { canReadAgentBoundResource } from './agentAccess'; +import { canReadAgentBoundResource, canReadSession } from './agentAccess'; export function toWireTurn(record: TurnRecordWithoutSnapshot): Turn { return { @@ -574,15 +574,15 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, - created_by_subject_id: session.record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadSession({ + shared: session.record.shared, + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, + created_by_subject_id: session.record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } try { @@ -609,15 +609,15 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, - created_by_subject_id: session.record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadSession({ + shared: session.record.shared, + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, + created_by_subject_id: session.record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turn = await session.getTurn(turnId); @@ -709,15 +709,15 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, - created_by_subject_id: session.record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadSession({ + shared: session.record.shared, + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, + created_by_subject_id: session.record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turn = await session.getTurn(turnId); @@ -852,15 +852,14 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !(await canReadAgentBoundResource({ - store: deps.resolveAgentStore(c), - context: requestContext, - authorizer: deps.authorizer, - agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, - created_by_subject_id: session.record.created_by_subject.subject_id, - })) - ) { + const allowed = await canReadAgentBoundResource({ + store: deps.resolveAgentStore(c), + context: requestContext, + authorizer: deps.authorizer, + agent_id: session.record.agent.type === 'reference' ? session.record.agent.id : undefined, + created_by_subject_id: session.record.created_by_subject.subject_id, + }); + if (!allowed) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turn = await session.getTurn(turnId); diff --git a/packages/trueforge/src/routes/sessionRoutes.ts b/packages/trueforge/src/routes/sessionRoutes.ts index 3031258e4..e4a8b2eb1 100644 --- a/packages/trueforge/src/routes/sessionRoutes.ts +++ b/packages/trueforge/src/routes/sessionRoutes.ts @@ -265,7 +265,7 @@ export const listSessionEventsRoute = createRoute({ tags: [OpenApiTag.AGENT_SESSIONS], summary: 'List session events', description: - 'List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events.', + 'List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.', 'x-fern-sdk-group-name': ['sessions'], 'x-fern-sdk-method-name': 'list_events', 'x-fern-pagination': TOKEN_PAGINATION, @@ -284,7 +284,7 @@ export const listSessionEventsRoute = createRoute({ }, 403: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Caller is not the session creator.', + description: 'Caller cannot read this session.', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/src/routes/turnRoutes.ts b/packages/trueforge/src/routes/turnRoutes.ts index 14f9b9398..2f19fa238 100644 --- a/packages/trueforge/src/routes/turnRoutes.ts +++ b/packages/trueforge/src/routes/turnRoutes.ts @@ -30,7 +30,7 @@ export const listTurnsRoute = createRoute({ tags: [OpenApiTag.AGENT_SESSIONS], summary: 'List turns in a session', description: - 'List turns for a session (newest first by default), token-paginated. Only the session creator may list turns.', + 'List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.', 'x-fern-sdk-group-name': ['sessions'], 'x-fern-sdk-method-name': 'list_turns', 'x-fern-pagination': TOKEN_PAGINATION, @@ -49,7 +49,7 @@ export const listTurnsRoute = createRoute({ }, 403: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Caller is not the session creator.', + description: 'Caller cannot read this session.', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -63,7 +63,8 @@ export const getTurnRoute = createRoute({ path: '/{session_id}/turns/{turn_id}', tags: [OpenApiTag.AGENT_SESSIONS], summary: 'Get a turn', - description: 'Fetch a single turn by ID. Only the session creator may fetch it.', + description: + 'Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.', 'x-fern-sdk-group-name': ['sessions'], 'x-fern-sdk-method-name': 'get_turn', request: { @@ -76,7 +77,7 @@ export const getTurnRoute = createRoute({ }, 403: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Caller is not the session creator.', + description: 'Caller cannot read this session.', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -140,7 +141,7 @@ export const listTurnEventsRoute = createRoute({ tags: [OpenApiTag.AGENT_SESSIONS], summary: 'List turn events', description: - 'Paginated persisted events for a turn (insertion order by default). Only the session creator may list events.', + 'Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.', 'x-fern-sdk-group-name': ['sessions'], 'x-fern-sdk-method-name': 'list_turn_events', 'x-fern-pagination': TOKEN_PAGINATION, @@ -159,7 +160,7 @@ export const listTurnEventsRoute = createRoute({ }, 403: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Caller is not the session creator.', + description: 'Caller cannot read this session.', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/src/schemas/session.ts b/packages/trueforge/src/schemas/session.ts index a711ebbf3..6985cb05f 100644 --- a/packages/trueforge/src/schemas/session.ts +++ b/packages/trueforge/src/schemas/session.ts @@ -68,7 +68,10 @@ export const UpdateSessionRequestSchema = z agent: SessionAgentSpecBodySchema.optional(), title: SessionTitleSchema.optional(), metadata: SessionMetadataSchema.optional(), - shared: z.boolean().optional().describe('When true, any subject in the tenant may fetch this session by id.'), + shared: z + .boolean() + .optional() + .describe('When true, any subject in the tenant may read this session and its turns/events by id.'), }) .strict() .openapi('UpdateSessionRequest'); diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index b8bdc9be2..7320a88f1 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -194,6 +194,77 @@ describe('turns', () => { ).status, ).toBe(403); }); + + it('lets any tenant member read a shared session but keeps create, subscribe, and sandbox download creator-only', async () => { + const db = createSqliteDb(':memory:'); + await migrateSqliteToLatest(db); + const sessionStore = new SqliteSessionStore(db); + await sessionStore.createSession({ + tenant_id: 'default', + session_id: 'shared-session', + created_by_subject: { subject_id: 'someone-else', subject_type: 'user', subject_display_name: 'someone-else' }, + agent: { + type: 'inline', + spec: AgentSpecSchema.parse({ + model: { name: 'test-provider/test-model' }, + instructions: 'test', + }), + }, + custom: null, + metadata: {}, + external_id: null, + source: null, + }); + await sessionStore.updateSession({ + tenant_id: 'default', + session_id: 'shared-session', + agent: undefined, + title: undefined, + metadata: undefined, + shared: true, + }); + + const app = new OpenAPIHono(); + app.route( + '/', + createTurnsRouter({ + sessions: new Sessions({ sessionStore }), + sessionStore, + activeTurns: new ActiveTurnRegistry(), + resolveModelProviderStore: () => new SqliteModelProviderStore(db), + resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), + resolveSkillStore: () => new SqliteSkillStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), + eventSubscriptions: new EventSubscriptionRegistry(undefined), + resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), + resolveWebSearchProviderStore: () => new SqliteWebSearchProviderStore(db), + logger: createLogger({ silent: true }), + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, + authorizer: new TrueForgeAuthorizer(), + }), + ); + + expect((await app.request('/shared-session/turns')).status).toBe(200); + expect((await app.request('/shared-session/turns/missing')).status).toBe(404); + expect((await app.request('/shared-session/turns/missing/events')).status).toBe(404); + expect((await app.request('/shared-session/turns/missing/subscribe')).status).toBe(403); + expect( + ( + await app.request( + `/shared-session/turns/missing/download-sandbox-file?path=${encodeURIComponent('/workspace/file.txt')}`, + ) + ).status, + ).toBe(403); + expect( + ( + await app.request('/shared-session/turns', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ stream: false }), + }) + ).status, + ).toBe(403); + }); }); describe('create turn x-tfy-metadata', () => { From a6b56a5ac85d80efc81a21f22d4cf3599a15b1d9 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Wed, 23 Sep 2026 11:41:59 +0000 Subject: [PATCH 4/6] Regenerate OpenAPI document and SDKs --- .github/fern/openapi/openapi.json | 20 +++++++++---------- docs/openapi.json | 20 +++++++++---------- packages/trueforge-sdk/reference.md | 8 ++++---- .../api/resources/sessions/client/Client.ts | 8 ++++---- .../client/requests/UpdateSessionRequest.ts | 2 +- .../trueforge-sdk/src/api/types/Session.ts | 2 +- python/trueforge_sdk/reference.md | 10 +++++----- .../src/trueforge_sdk/sessions/client.py | 20 +++++++++---------- .../src/trueforge_sdk/sessions/raw_client.py | 20 +++++++++---------- .../src/trueforge_sdk/types/session.py | 2 +- 10 files changed, 56 insertions(+), 56 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index b7813614d..54b36bef3 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -3976,7 +3976,7 @@ "$ref": "#/components/schemas/SessionMetrics" }, "shared": { - "description": "When true, any subject in the tenant may fetch this session by id.", + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", "type": "boolean" }, "source": { @@ -5690,7 +5690,7 @@ "$ref": "#/components/schemas/SessionMetadata" }, "shared": { - "description": "When true, any subject in the tenant may fetch this session by id.", + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", "type": "boolean" }, "title": { @@ -8706,7 +8706,7 @@ }, "/api/v1/sessions/{session_id}/events": { "get": { - "description": "List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events.", + "description": "List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -8783,7 +8783,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -8813,7 +8813,7 @@ }, "/api/v1/sessions/{session_id}/turns": { "get": { - "description": "List turns for a session (newest first by default), token-paginated. Only the session creator may list turns.", + "description": "List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -8880,7 +8880,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9043,7 +9043,7 @@ }, "/api/v1/sessions/{session_id}/turns/{turn_id}": { "get": { - "description": "Fetch a single turn by ID. Only the session creator may fetch it.", + "description": "Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -9088,7 +9088,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9245,7 +9245,7 @@ }, "/api/v1/sessions/{session_id}/turns/{turn_id}/events": { "get": { - "description": "Paginated persisted events for a turn (insertion order by default). Only the session creator may list events.", + "description": "Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -9332,7 +9332,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { diff --git a/docs/openapi.json b/docs/openapi.json index b7813614d..54b36bef3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3976,7 +3976,7 @@ "$ref": "#/components/schemas/SessionMetrics" }, "shared": { - "description": "When true, any subject in the tenant may fetch this session by id.", + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", "type": "boolean" }, "source": { @@ -5690,7 +5690,7 @@ "$ref": "#/components/schemas/SessionMetadata" }, "shared": { - "description": "When true, any subject in the tenant may fetch this session by id.", + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", "type": "boolean" }, "title": { @@ -8706,7 +8706,7 @@ }, "/api/v1/sessions/{session_id}/events": { "get": { - "description": "List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events.", + "description": "List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -8783,7 +8783,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -8813,7 +8813,7 @@ }, "/api/v1/sessions/{session_id}/turns": { "get": { - "description": "List turns for a session (newest first by default), token-paginated. Only the session creator may list turns.", + "description": "List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -8880,7 +8880,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9043,7 +9043,7 @@ }, "/api/v1/sessions/{session_id}/turns/{turn_id}": { "get": { - "description": "Fetch a single turn by ID. Only the session creator may fetch it.", + "description": "Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -9088,7 +9088,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9245,7 +9245,7 @@ }, "/api/v1/sessions/{session_id}/turns/{turn_id}/events": { "get": { - "description": "Paginated persisted events for a turn (insertion order by default). Only the session creator may list events.", + "description": "Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.", "parameters": [ { "description": "Session identifier.", @@ -9332,7 +9332,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index aa69f497b..12d1850f4 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -1820,7 +1820,7 @@ await client.sessions.cancel("session_id");
-List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. +List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -1903,7 +1903,7 @@ const response = page.response;
-List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. +List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -2139,7 +2139,7 @@ await client.sessions.createTurn("session_id", {});
-Fetch a single turn by ID. Only the session creator may fetch it. +Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -2291,7 +2291,7 @@ await client.sessions.downloadSandboxFile("session_id", "turn_id", {
-Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. +Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
diff --git a/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts b/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts index ff22ba92d..7b8b62126 100644 --- a/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/sessions/client/Client.ts @@ -717,7 +717,7 @@ export class SessionsClient { } /** - * List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. + * List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. * * @param {string} session_id - Session identifier. * @param {TrueForge.ListEventsSessionsRequest} request @@ -850,7 +850,7 @@ export class SessionsClient { } /** - * List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. + * List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. * * @param {string} session_id - Session identifier. * @param {TrueForge.ListTurnsSessionsRequest} request @@ -1337,7 +1337,7 @@ export class SessionsClient { } /** - * Fetch a single turn by ID. Only the session creator may fetch it. + * Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. * * @param {string} session_id - Session identifier. * @param {string} turn_id - Turn identifier. @@ -1600,7 +1600,7 @@ export class SessionsClient { } /** - * Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. + * Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. * * @param {string} session_id - Session identifier. * @param {string} turn_id - Turn identifier. diff --git a/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts b/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts index 23fbd0d48..e55079969 100644 --- a/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts +++ b/packages/trueforge-sdk/src/api/resources/sessions/client/requests/UpdateSessionRequest.ts @@ -9,7 +9,7 @@ import type * as TrueForge from "../../../../index.js"; export interface UpdateSessionRequest { agent?: TrueForge.SessionAgentSpecBody; metadata?: TrueForge.SessionMetadata; - /** When true, any subject in the tenant may fetch this session by id. */ + /** When true, any subject in the tenant may read this session and its turns/events by id. */ shared?: boolean; /** Human-readable session title. */ title?: string; diff --git a/packages/trueforge-sdk/src/api/types/Session.ts b/packages/trueforge-sdk/src/api/types/Session.ts index 2818a4bb6..7d3a0614e 100644 --- a/packages/trueforge-sdk/src/api/types/Session.ts +++ b/packages/trueforge-sdk/src/api/types/Session.ts @@ -11,7 +11,7 @@ export interface Session { id: string; metadata: TrueForge.SessionMetadata; metrics: TrueForge.SessionMetrics; - /** When true, any subject in the tenant may fetch this session by id. */ + /** When true, any subject in the tenant may read this session and its turns/events by id. */ shared: boolean; source: TrueForge.SessionSource | null; /** Optional human-readable title; null until set. */ diff --git a/python/trueforge_sdk/reference.md b/python/trueforge_sdk/reference.md index d308f86c1..3f63c5184 100644 --- a/python/trueforge_sdk/reference.md +++ b/python/trueforge_sdk/reference.md @@ -2079,7 +2079,7 @@ client.sessions.update(
-**shared:** `typing.Optional[bool]` — When true, any subject in the tenant may fetch this session by id. +**shared:** `typing.Optional[bool]` — When true, any subject in the tenant may read this session and its turns/events by id.
@@ -2191,7 +2191,7 @@ client.sessions.cancel(
-List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. +List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -2287,7 +2287,7 @@ client.sessions.list_events(
-List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. +List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -2575,7 +2575,7 @@ client.sessions.create_turn_stream(
-Fetch a single turn by ID. Only the session creator may fetch it. +Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
@@ -2746,7 +2746,7 @@ client.sessions.download_sandbox_file(
-Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. +Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared.
diff --git a/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py b/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py index 1b20bcbfd..b7e72f093 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py @@ -262,7 +262,7 @@ def update( metadata : typing.Optional[SessionMetadata] shared : typing.Optional[bool] - When true, any subject in the tenant may fetch this session by id. + When true, any subject in the tenant may read this session and its turns/events by id. title : typing.Optional[str] Human-readable session title. @@ -341,7 +341,7 @@ def list_events( request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[SessionEventItem, ListSessionEventsResponse]: """ - List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. + List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -399,7 +399,7 @@ def list_turns( request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[Turn, ListTurnsResponse]: """ - List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. + List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -551,7 +551,7 @@ def get_turn( self, *, session_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None ) -> GetTurnResponse: """ - Fetch a single turn by ID. Only the session creator may fetch it. + Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -640,7 +640,7 @@ def list_turn_events( request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[SessionEvent, ListTurnEventsResponse]: """ - Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. + Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1016,7 +1016,7 @@ async def update( metadata : typing.Optional[SessionMetadata] shared : typing.Optional[bool] - When true, any subject in the tenant may fetch this session by id. + When true, any subject in the tenant may read this session and its turns/events by id. title : typing.Optional[str] Human-readable session title. @@ -1111,7 +1111,7 @@ async def list_events( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[SessionEventItem, ListSessionEventsResponse]: """ - List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. + List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1178,7 +1178,7 @@ async def list_turns( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[Turn, ListTurnsResponse]: """ - List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. + List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1356,7 +1356,7 @@ async def get_turn( self, *, session_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None ) -> GetTurnResponse: """ - Fetch a single turn by ID. Only the session creator may fetch it. + Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1464,7 +1464,7 @@ async def list_turn_events( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[SessionEvent, ListTurnEventsResponse]: """ - Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. + Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- diff --git a/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py b/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py index 9e2255208..4864c8809 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py @@ -406,7 +406,7 @@ def update( metadata : typing.Optional[SessionMetadata] shared : typing.Optional[bool] - When true, any subject in the tenant may fetch this session by id. + When true, any subject in the tenant may read this session and its turns/events by id. title : typing.Optional[str] Human-readable session title. @@ -590,7 +590,7 @@ def list_events( request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[SessionEventItem, ListSessionEventsResponse]: """ - List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. + List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -698,7 +698,7 @@ def list_turns( request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[Turn, ListTurnsResponse]: """ - List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. + List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1127,7 +1127,7 @@ def get_turn( self, *, session_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None ) -> HttpResponse[GetTurnResponse]: """ - Fetch a single turn by ID. Only the session creator may fetch it. + Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1338,7 +1338,7 @@ def list_turn_events( request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[SessionEvent, ListTurnEventsResponse]: """ - Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. + Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -1936,7 +1936,7 @@ async def update( metadata : typing.Optional[SessionMetadata] shared : typing.Optional[bool] - When true, any subject in the tenant may fetch this session by id. + When true, any subject in the tenant may read this session and its turns/events by id. title : typing.Optional[str] Human-readable session title. @@ -2120,7 +2120,7 @@ async def list_events( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[SessionEventItem, ListSessionEventsResponse]: """ - List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Only the session creator may list events. + List session events as `{ turn_id, event }` across the active turn branch (newest first), including persisted events from a running tip. Each turn contributes turn.created, content events (model.message, tool.call, …), and turn.done when terminal; streaming deltas are not included. Use `page_token` to paginate backward toward older events while retaining the original branch anchor. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -2231,7 +2231,7 @@ async def list_turns( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[Turn, ListTurnsResponse]: """ - List turns for a session (newest first by default), token-paginated. Only the session creator may list turns. + List turns for a session (newest first by default), token-paginated. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -2663,7 +2663,7 @@ async def get_turn( self, *, session_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[GetTurnResponse]: """ - Fetch a single turn by ID. Only the session creator may fetch it. + Fetch a single turn by ID. Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- @@ -2875,7 +2875,7 @@ async def list_turn_events( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[SessionEvent, ListTurnEventsResponse]: """ - Paginated persisted events for a turn (insertion order by default). Only the session creator may list events. + Paginated persisted events for a turn (insertion order by default). Allowed for the creator, a manager of the bound named agent, or any tenant member when the session is shared. Parameters ---------- diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/session.py b/python/trueforge_sdk/src/trueforge_sdk/types/session.py index f2700b292..3f2f1baa9 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/types/session.py +++ b/python/trueforge_sdk/src/trueforge_sdk/types/session.py @@ -29,7 +29,7 @@ class Session(UncheckedBaseModel): metrics: SessionMetrics shared: bool = pydantic.Field() """ - When true, any subject in the tenant may fetch this session by id. + When true, any subject in the tenant may read this session and its turns/events by id. """ source: typing.Optional[SessionSource] = None From 375f5fb08befa8058b86601ab560f8f186bd19ad Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Thu, 24 Sep 2026 11:55:41 +0530 Subject: [PATCH 5/6] Rename migrations --- ...0001_session_shared.ts => 20260924_000002_session_shared.ts} | 0 ...0001_session_shared.ts => 20260924_000002_session_shared.ts} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/trueforge/src/db/postgres/migrations/{20260923_000001_session_shared.ts => 20260924_000002_session_shared.ts} (100%) rename packages/trueforge/src/db/sqlite/migrations/{20260923_000001_session_shared.ts => 20260924_000002_session_shared.ts} (90%) diff --git a/packages/trueforge/src/db/postgres/migrations/20260923_000001_session_shared.ts b/packages/trueforge/src/db/postgres/migrations/20260924_000002_session_shared.ts similarity index 100% rename from packages/trueforge/src/db/postgres/migrations/20260923_000001_session_shared.ts rename to packages/trueforge/src/db/postgres/migrations/20260924_000002_session_shared.ts diff --git a/packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts b/packages/trueforge/src/db/sqlite/migrations/20260924_000002_session_shared.ts similarity index 90% rename from packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts rename to packages/trueforge/src/db/sqlite/migrations/20260924_000002_session_shared.ts index 8baed8bb8..2baaa9a64 100644 --- a/packages/trueforge/src/db/sqlite/migrations/20260923_000001_session_shared.ts +++ b/packages/trueforge/src/db/sqlite/migrations/20260924_000002_session_shared.ts @@ -2,7 +2,7 @@ import { sql, type Kysely } from 'kysely'; /** * Tenant-visible share flag. Existing sessions stay private. - * Mirrors db/postgres/migrations/20260923_000001_session_shared.ts. + * Mirrors db/postgres/migrations/20260924_000002_session_shared.ts. * Kysely does not wrap SQLite migrations — keep schema changes in a transaction. */ export async function up(db: Kysely): Promise { From 29e3108b93f4803489313c3250f8c696bef91f9a Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Thu, 24 Sep 2026 12:08:43 +0530 Subject: [PATCH 6/6] Add contract test for shared flag updates --- .../src/agent-session/store/ISessionStore.ts | 1 + .../agent-session/store/storeContractSuite.ts | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index 030171946..c41b2cb63 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -295,6 +295,7 @@ export interface ISessionStore< * - agent: replace inline binding (inline sessions only; reference → invariant error). * - title: set/replace the session title. * - metadata: full replace of the caller-owned string map when set. + * - shared: set/replace the share flag when set; omitted leaves the stored value. * Bumps `last_activity_timestamp_ms` (= now) in the same update. */ updateSession(input: UpdateSessionInput): Promise; diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 7e7fe1415..b9ac5e04d 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -384,6 +384,50 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).metadata).toEqual({}); }); + it('createSession defaults shared to false', async () => { + const store = createStore(); + await seedSession(store); + expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).shared).toBe(false); + }); + + it('updateSession patches shared when set and leaves it when omitted', async () => { + const store = createStore(); + await seedSession(store); + expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).shared).toBe(false); + + await store.updateSession({ + tenant_id: tenant, + session_id: sessionId, + agent: undefined, + title: undefined, + metadata: undefined, + shared: true, + }); + expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).shared).toBe(true); + + await store.updateSession({ + tenant_id: tenant, + session_id: sessionId, + agent: undefined, + title: 'keep-shared', + metadata: undefined, + shared: undefined, + }); + const afterOmit = mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })); + expect(afterOmit.title).toBe('keep-shared'); + expect(afterOmit.shared).toBe(true); + + await store.updateSession({ + tenant_id: tenant, + session_id: sessionId, + agent: undefined, + title: undefined, + metadata: undefined, + shared: false, + }); + expect(mustGet(await store.getSession({ tenant_id: tenant, session_id: sessionId })).shared).toBe(false); + }); + it('createSession conflict when session already exists', async () => { const store = createStore(); await seedSession(store);