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/.changeset/session-shared-flag.md b/.changeset/session-shared-flag.md new file mode 100644 index 000000000..428bb44e8 --- /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 read it by id, including turns and events (not subscribe or sandbox downloads). diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 6fe2f9f7c..5b9fb098e 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -4028,6 +4028,10 @@ "metrics": { "$ref": "#/components/schemas/SessionMetrics" }, + "shared": { + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", + "type": "boolean" + }, "source": { "$ref": "#/components/schemas/SessionSource" }, @@ -4047,6 +4051,7 @@ "id", "agent", "title", + "shared", "created_by_subject", "created_at", "updated_at", @@ -5832,6 +5837,10 @@ "metadata": { "$ref": "#/components/schemas/SessionMetadata" }, + "shared": { + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", + "type": "boolean" + }, "title": { "description": "Human-readable session title.", "maxLength": 50, @@ -8802,7 +8811,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.", @@ -8836,7 +8845,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -8859,7 +8868,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.", @@ -9027,7 +9036,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.", @@ -9104,7 +9113,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9134,7 +9143,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.", @@ -9201,7 +9210,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9364,7 +9373,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.", @@ -9409,7 +9418,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9566,7 +9575,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.", @@ -9653,7 +9662,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 6fe2f9f7c..5b9fb098e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -4028,6 +4028,10 @@ "metrics": { "$ref": "#/components/schemas/SessionMetrics" }, + "shared": { + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", + "type": "boolean" + }, "source": { "$ref": "#/components/schemas/SessionSource" }, @@ -4047,6 +4051,7 @@ "id", "agent", "title", + "shared", "created_by_subject", "created_at", "updated_at", @@ -5832,6 +5837,10 @@ "metadata": { "$ref": "#/components/schemas/SessionMetadata" }, + "shared": { + "description": "When true, any subject in the tenant may read this session and its turns/events by id.", + "type": "boolean" + }, "title": { "description": "Human-readable session title.", "maxLength": 50, @@ -8802,7 +8811,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.", @@ -8836,7 +8845,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -8859,7 +8868,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.", @@ -9027,7 +9036,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.", @@ -9104,7 +9113,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9134,7 +9143,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.", @@ -9201,7 +9210,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9364,7 +9373,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.", @@ -9409,7 +9418,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { @@ -9566,7 +9575,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.", @@ -9653,7 +9662,7 @@ } } }, - "description": "Caller is not the session creator." + "description": "Caller cannot read this session." }, "404": { "content": { 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..94ac131f8 100644 --- a/packages/trueforge-core/src/agent-session/schemas/session.ts +++ b/packages/trueforge-core/src/agent-session/schemas/session.ts @@ -105,6 +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 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-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index e423cfff6..c41b2cb63 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 { @@ -293,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/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index a0a19615a..b1ff71900 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..b9ac5e04d 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,10 +379,55 @@ 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({}); }); + 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); @@ -648,6 +697,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 +852,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: 'new-title', metadata: undefined, + shared: undefined, }), ).rejects.toBeInstanceOf(SessionNotFoundError); await expect( @@ -989,6 +1040,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: 'bumped', metadata: undefined, + shared: undefined, }); const listArgs = { @@ -2647,6 +2699,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { agent: undefined, title: jsonLooking, metadata: undefined, + shared: undefined, }); await store.createTurn( makeCreateTurnInput({ diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 40b7f73b3..cbb977225 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.
@@ -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 877b563c8..2faf08eef 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 @@ -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 247dc69bc..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,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 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 eaf080418..7d3a0614e 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 read this session and its turns/events 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 19226ab2f..bdd8e61fc 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/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 cbdf0a944..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. */ @@ -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(), @@ -255,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); @@ -304,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); }; @@ -388,15 +388,15 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { if (!record) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if ( - !(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); @@ -467,6 +467,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) { @@ -561,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 c74b9ebcb..e7a8dd1b8 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -67,7 +67,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 { @@ -576,15 +576,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 { @@ -611,15 +611,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); @@ -711,15 +711,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); @@ -854,15 +854,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/db/postgres/migrations/20260924_000002_session_shared.ts b/packages/trueforge/src/db/postgres/migrations/20260924_000002_session_shared.ts new file mode 100644 index 000000000..2b38815e4 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260924_000002_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/20260924_000002_session_shared.ts b/packages/trueforge/src/db/sqlite/migrations/20260924_000002_session_shared.ts new file mode 100644 index 000000000..2baaa9a64 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260924_000002_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/20260924_000002_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..e4a8b2eb1 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: { @@ -264,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, @@ -283,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 2a270a2af..2d5ae90a9 100644 --- a/packages/trueforge/src/routes/turnRoutes.ts +++ b/packages/trueforge/src/routes/turnRoutes.ts @@ -34,7 +34,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, @@ -53,7 +53,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 } }, @@ -67,7 +67,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: { @@ -80,7 +81,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 } }, @@ -144,7 +145,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, @@ -163,7 +164,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 2dc7b4530..6985cb05f 100644 --- a/packages/trueforge/src/schemas/session.ts +++ b/packages/trueforge/src/schemas/session.ts @@ -68,6 +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 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 db85a17a8..fc1e20c4e 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -229,6 +229,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', () => { diff --git a/python/trueforge_sdk/reference.md b/python/trueforge_sdk/reference.md index 0dd136f93..29caa7ba4 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 read this session and its turns/events by id. + +
+
+ +
+
+ **title:** `typing.Optional[str]` — Human-readable session title.
@@ -2183,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.
@@ -2279,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.
@@ -2567,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.
@@ -2738,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 0bfcccc4a..87d2ee10a 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/sessions/client.py @@ -180,7 +180,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 ---------- @@ -247,11 +247,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 ---------- @@ -262,6 +263,9 @@ def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + 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. @@ -286,7 +290,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 @@ -334,7 +343,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 ---------- @@ -392,7 +401,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 ---------- @@ -544,7 +553,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 ---------- @@ -633,7 +642,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 ---------- @@ -958,7 +967,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 ---------- @@ -1041,11 +1050,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 ---------- @@ -1056,6 +1066,9 @@ async def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + 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. @@ -1088,7 +1101,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 @@ -1144,7 +1162,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 ---------- @@ -1211,7 +1229,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 ---------- @@ -1389,7 +1407,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 ---------- @@ -1497,7 +1515,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 86af6497b..ff7246344 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/sessions/raw_client.py @@ -281,7 +281,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 ---------- @@ -392,11 +392,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 ---------- @@ -407,6 +408,9 @@ def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + 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. @@ -426,6 +430,7 @@ def update( object_=agent, annotation=SessionAgentSpecBody, direction="write" ), "metadata": metadata, + "shared": shared, "title": title, }, headers={ @@ -588,7 +593,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 ---------- @@ -696,7 +701,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 ---------- @@ -1125,7 +1130,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 ---------- @@ -1336,7 +1341,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 ---------- @@ -1911,7 +1916,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 ---------- @@ -2024,11 +2029,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 ---------- @@ -2039,6 +2045,9 @@ async def update( metadata : typing.Optional[SessionMetadata] + shared : typing.Optional[bool] + 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. @@ -2058,6 +2067,7 @@ async def update( object_=agent, annotation=SessionAgentSpecBody, direction="write" ), "metadata": metadata, + "shared": shared, "title": title, }, headers={ @@ -2220,7 +2230,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 ---------- @@ -2331,7 +2341,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 ---------- @@ -2763,7 +2773,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 ---------- @@ -2975,7 +2985,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 69b629dc6..3f2f1baa9 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 read this session and its turns/events by id. + """ + source: typing.Optional[SessionSource] = None title: typing.Optional[str] = pydantic.Field(default=None) """