From 5f1752bd485a8ab5ec3822fea13bc7074a5c8ab4 Mon Sep 17 00:00:00 2001 From: ajspig Date: Tue, 4 Aug 2026 15:37:00 -0400 Subject: [PATCH] feat(typescript): conclusion attribution parity with Python SDK - Conclusion class: sourceIds + timesDerived fields (appended to the constructor signature with defaults, so existing positional callers are unaffected) - ConclusionScope: get(), getMany() (batch fetch via the list endpoint with an id-in filter, chunked at the 100 page-size cap), and derived() (paginated reverse traversal via GET /conclusions/{id}/derived) - ConclusionResponse type: optional source_ids / times_derived Co-Authored-By: Claude Fable 5 --- sdks/typescript/__tests__/conclusions.test.ts | 108 ++++++++++++++ sdks/typescript/src/conclusions.ts | 133 +++++++++++++++++- sdks/typescript/src/types/api.ts | 2 + 3 files changed, 240 insertions(+), 3 deletions(-) diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index 7716d3ac..a77ac435 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -333,6 +333,91 @@ describe('Conclusions', () => { }) }) + // =========================================================================== + // Single Conclusion Retrieval (GET /conclusions/:id) + // =========================================================================== + + describe('GET /conclusions/:id (get)', () => { + test('get returns conclusion with attribution fields', async () => { + const peer = await client.peer('get-conclusion-peer', { metadata: {} }) + const session = await client.session('get-conclusion-session', { metadata: {} }) + + const [created] = await peer.conclusions.create({ + content: 'Conclusion to fetch', + sessionId: session, + }) + + const fetched = await peer.conclusions.get(created.id) + + expect(fetched).toBeInstanceOf(Conclusion) + expect(fetched.id).toBe(created.id) + expect(fetched.content).toBe('Conclusion to fetch') + expect(fetched.observerId).toBe(peer.id) + expect(fetched.observedId).toBe(peer.id) + expect(fetched.level).toBe('explicit') + // User-created conclusions are explicit: no premises, derived once + expect(fetched.sourceIds).toBeNull() + expect(fetched.timesDerived).toBe(1) + }) + }) + + // =========================================================================== + // Batch Retrieval (getMany) + // =========================================================================== + + describe('getMany', () => { + test('fetches multiple conclusions by ID', async () => { + const peer = await client.peer('get-many-conclusion-peer', { metadata: {} }) + const session = await client.session('get-many-conclusion-session', { metadata: {} }) + + const created = await peer.conclusions.create([ + { content: 'First batch conclusion', sessionId: session }, + { content: 'Second batch conclusion', sessionId: session }, + { content: 'Third batch conclusion', sessionId: session }, + ]) + + const fetched = await peer.conclusions.getMany(created.map((c) => c.id)) + + expect(fetched.length).toBe(3) + for (const conclusion of fetched) { + expect(conclusion).toBeInstanceOf(Conclusion) + } + expect(new Set(fetched.map((c) => c.id))).toEqual( + new Set(created.map((c) => c.id)) + ) + }) + + test('empty input returns empty array', async () => { + const peer = await client.peer('get-many-empty-peer', { metadata: {} }) + + const fetched = await peer.conclusions.getMany([]) + + expect(fetched).toEqual([]) + }) + }) + + // =========================================================================== + // Derived Conclusions (GET /conclusions/:id/derived) + // =========================================================================== + + describe('GET /conclusions/:id/derived', () => { + test('leaf conclusion has no derived conclusions', async () => { + const peer = await client.peer('derived-conclusion-peer', { metadata: {} }) + const session = await client.session('derived-conclusion-session', { metadata: {} }) + + // User-created (explicit) conclusions have nothing derived from them + const [created] = await peer.conclusions.create({ + content: 'Leaf conclusion', + sessionId: session, + }) + + const page = await peer.conclusions.derived(created.id) + + expect(page.items).toEqual([]) + expect(page.total).toBe(0) + }) + }) + // =========================================================================== // Conclusion Deletion (DELETE /conclusions/:id) // =========================================================================== @@ -429,6 +514,29 @@ describe('Conclusions', () => { expect(conclusion.observedId).toBe('observed') expect(conclusion.sessionId).toBe('session') expect(conclusion.createdAt).toBe('2024-01-15T10:00:00Z') + // Attribution fields default when absent from the response + expect(conclusion.sourceIds).toBeNull() + expect(conclusion.timesDerived).toBe(1) + }) + + test('fromApiResponse carries attribution fields', () => { + const response = { + id: 'derived-id', + content: 'Derived conclusion', + observer_id: 'observer', + observed_id: 'observed', + session_id: null, + level: 'deductive' as const, + source_ids: ['premise-1', 'premise-2'], + times_derived: 3, + created_at: '2024-01-15T10:00:00Z', + } + + const conclusion = Conclusion.fromApiResponse(response) + + expect(conclusion.level).toBe('deductive') + expect(conclusion.sourceIds).toEqual(['premise-1', 'premise-2']) + expect(conclusion.timesDerived).toBe(3) }) test('toString returns readable format', async () => { diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index c9add4e2..288ae4ea 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -76,6 +76,14 @@ export class Conclusion { * dreaming. */ readonly level: ConclusionLevel + /** + * IDs of the conclusions this one was derived from (premises for + * 'deductive', supporting sources for 'inductive', conflicting conclusions + * for 'contradiction'). Null for 'explicit' conclusions. + */ + readonly sourceIds: string[] | null + /** Number of times this conclusion has been independently derived. */ + readonly timesDerived: number readonly createdAt: string constructor( @@ -85,7 +93,9 @@ export class Conclusion { observedId: string, sessionId: string | null, createdAt: string, - level: ConclusionLevel = 'explicit' + level: ConclusionLevel = 'explicit', + sourceIds: string[] | null = null, + timesDerived: number = 1 ) { this.id = id this.content = content @@ -93,6 +103,8 @@ export class Conclusion { this.observedId = observedId this.sessionId = sessionId this.level = level + this.sourceIds = sourceIds + this.timesDerived = timesDerived this.createdAt = createdAt } @@ -104,7 +116,9 @@ export class Conclusion { data.observed_id, data.session_id, data.created_at, - data.level + data.level, + data.source_ids ?? null, + data.times_derived ?? 1 ) } @@ -193,6 +207,34 @@ export class ConclusionScope { ) } + private async _get(conclusionId: string): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}` + ) + } + + private async _derived( + conclusionId: string, + params: { + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + await this._ensureWorkspace() + return this._http.get>( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}/derived`, + { + query: { + page: params.page, + size: params.size, + reverse: params.reverse ? 'true' : undefined, + }, + } + ) + } + private async _delete(conclusionId: string): Promise { await this._ensureWorkspace() await this._http.delete( @@ -233,7 +275,8 @@ export class ConclusionScope { * this scope's observer/observed (and session, if given). Supports the same * operators as other list endpoints — e.g. `{ level: 'explicit' }` to get * only conclusions extracted directly from messages (i.e. not derived during - * dreaming). See + * dreaming), or `{ source_ids: { contains: '' } }` to get conclusions + * derived from a given conclusion (see also `derived()`). See * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters * @returns Promise resolving to a Page of Conclusion objects */ @@ -316,6 +359,90 @@ export class ConclusionScope { return (response ?? []).map((item) => Conclusion.fromApiResponse(item)) } + /** + * Get a single conclusion by ID. + * + * @param conclusionId - The ID of the conclusion to retrieve + * @returns Promise resolving to the Conclusion object, including its + * attribution fields (`sourceIds`, `timesDerived`) + */ + async get(conclusionId: string): Promise { + const response = await this._get(conclusionId) + return Conclusion.fromApiResponse(response) + } + + /** + * Get multiple conclusions by ID in a single call. + * + * Useful for resolving a derived conclusion's premises: pass its + * `sourceIds` to fetch all of them at once instead of one `get()` per ID. + * + * @param conclusionIds - The IDs of the conclusions to retrieve + * @returns Promise resolving to the matching Conclusion objects. IDs that + * don't exist are omitted, so the result may be shorter than the input + * (order is not guaranteed to match the input either). + */ + async getMany(conclusionIds: string[]): Promise { + if (conclusionIds.length === 0) return [] + const conclusions: Conclusion[] = [] + // The list endpoint caps page size at 100 + for (let start = 0; start < conclusionIds.length; start += 100) { + const chunk = conclusionIds.slice(start, start + 100) + const response = await this._list({ + filters: { id: { in: chunk } }, + page: 1, + size: chunk.length, + }) + conclusions.push( + ...(response.items ?? []).map((item) => + Conclusion.fromApiResponse(item) + ) + ) + } + return conclusions + } + + /** + * Get the conclusions derived from the given conclusion — i.e. those that + * list it in their `sourceIds`. Traverses the reasoning tree upward + * (source -> derived). + * + * @param conclusionId - The ID of the source conclusion + * @param options - Optional configuration for the request + * @param options.page - Page number (1-indexed, default: 1) + * @param options.size - Number of items per page (default: 50) + * @param options.reverse - If true, reverses the default newest-first ordering + * @returns Promise resolving to a Page of Conclusion objects + */ + async derived( + conclusionId: string, + options?: { + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + const reverse = options?.reverse + const response = await this._derived(conclusionId, { + page: options?.page ?? 1, + size: options?.size ?? 50, + reverse, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._derived(conclusionId, { page, size, reverse }) + } + + return new Page( + response, + (item) => Conclusion.fromApiResponse(item), + fetchNextPage + ) + } + /** * Delete a conclusion by ID. */ diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index dbe4378d..fdd3eee5 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -260,6 +260,8 @@ export interface ConclusionResponse { observed_id: string session_id: string | null level: ConclusionLevel + source_ids?: string[] | null + times_derived?: number created_at: string }