From 833b8067dda9dcecf317bdd8334748ce87a3ae75 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 3 Sep 2026 13:19:58 -0400 Subject: [PATCH 1/2] feat(sdk): surface dialectic evidence in the TypeScript SDK Accept `includeEvidence` on peer and workspace chat. Opting in resolves to a `ChatResponse` carrying the answer alongside what the dialectic read to produce it; leaving it out resolves to the answer on its own, so existing callers are unaffected. Overloads discriminate on the flag's literal value, so a Zod `responseFormat` combined with evidence types as `ChatResponse`. `createDialecticStream` returned as soon as it saw a chunk marked done and discarded the rest of that chunk, which is where the server sends evidence -- it cannot be known until the answer is complete. It now reads the terminal chunk before returning, and the stream response exposes what it found as `evidence` once drained. Every streaming caller goes through this function, so the content chunks it yields are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/evidence.unit.test.ts | 148 ++++++++++++++++++ sdks/typescript/__tests__/peer.test.ts | 31 ++++ sdks/typescript/src/client.ts | 50 +++++- sdks/typescript/src/http/streaming.ts | 28 +++- sdks/typescript/src/index.ts | 5 + sdks/typescript/src/peer.ts | 58 ++++++- sdks/typescript/src/types/api.ts | 55 +++++++ sdks/typescript/src/validation.ts | 1 + 8 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 sdks/typescript/__tests__/evidence.unit.test.ts diff --git a/sdks/typescript/__tests__/evidence.unit.test.ts b/sdks/typescript/__tests__/evidence.unit.test.ts new file mode 100644 index 00000000..8b6711e1 --- /dev/null +++ b/sdks/typescript/__tests__/evidence.unit.test.ts @@ -0,0 +1,148 @@ +/** + * Evidence Unit Tests + * + * Evidence rides on the terminal chunk of a dialectic stream, which the + * stream reader would otherwise discard. These tests drive mocked SSE bodies + * so they cover that without a server. + */ + +import { describe, test, expect } from 'bun:test' +import { createDialecticStream } from '../src/http/streaming' +import type { Evidence } from '../src/types/api' + +const EVIDENCE: Evidence = { + conclusions: [ + { + id: 'doc-sentinel', + level: 'deductive', + content: 'User drinks coffee in the morning', + created_at: '2026-01-01T00:00:00Z', + session_id: 'session-1', + source_ids: ['doc-a', 'doc-b'], + }, + ], + messages: [ + { + id: 'msg-sentinel', + session_id: 'session-1', + peer_id: 'alice', + content_preview: 'I drink a lot of coffee', + created_at: '2026-01-01T00:00:00Z', + }, + ], + tool_calls: [{ tool_name: 'search_memory', tool_input: { query: 'coffee' } }], + reasoning_trace_id: null, +} + +function mockSSEResponse(lines: string[]): Response { + const encoder = new TextEncoder() + let index = 0 + const stream = new ReadableStream({ + pull(controller) { + if (index < lines.length) { + controller.enqueue(encoder.encode(lines[index])) + index++ + } else { + controller.close() + } + }, + }) + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) +} + +function contentFrame(content: string): string { + return `data: ${JSON.stringify({ delta: { content }, done: false })}\n\n` +} + +function doneFrame(evidence?: Evidence): string { + return `data: ${JSON.stringify({ done: true, ...(evidence && { evidence })})}\n\n` +} + +describe('streamed evidence', () => { + test('is captured off the terminal chunk', async () => { + const stream = createDialecticStream( + mockSSEResponse([ + contentFrame('The user '), + contentFrame('drinks coffee.'), + doneFrame(EVIDENCE), + ]) + ) + + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.join('')).toBe('The user drinks coffee.') + expect(stream.evidence).toEqual(EVIDENCE) + }) + + test('survives being split across network chunks', async () => { + const frame = doneFrame(EVIDENCE) + const midpoint = Math.floor(frame.length / 2) + const stream = createDialecticStream( + mockSSEResponse([ + contentFrame('Answer.'), + frame.slice(0, midpoint), + frame.slice(midpoint), + ]) + ) + + await stream.getFinalResponse() + + expect(stream.evidence?.conclusions[0]?.id).toBe('doc-sentinel') + }) + + test('is null until the stream has been consumed', async () => { + const stream = createDialecticStream( + mockSSEResponse([ + contentFrame('Answer.'), + doneFrame(EVIDENCE), + ]) + ) + + // Evidence cannot be known before the answer is complete. + expect(stream.evidence).toBeNull() + + await stream.getFinalResponse() + + expect(stream.evidence).not.toBeNull() + }) + + test('is null when the request did not ask for it', async () => { + const stream = createDialecticStream( + mockSSEResponse([ + contentFrame('Answer.'), + doneFrame(), + ]) + ) + + await stream.getFinalResponse() + + expect(stream.evidence).toBeNull() + }) + + test('does not disturb the content chunks', async () => { + const withEvidence = createDialecticStream( + mockSSEResponse([ + contentFrame('a'), + contentFrame('b'), + doneFrame(EVIDENCE), + ]) + ) + const withoutEvidence = createDialecticStream( + mockSSEResponse([ + contentFrame('a'), + contentFrame('b'), + doneFrame(), + ]) + ) + + expect(await withEvidence.toArray()).toEqual( + await withoutEvidence.toArray() + ) + }) +}) diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index cde6a0ad..b99bcfc3 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -590,6 +590,37 @@ describe('Peer', () => { expect(response === null || typeof response === 'string').toBe(true) }) + test('chat with includeEvidence returns answer and evidence', async () => { + const peer = await client.peer('chat-evidence-peer') + const session = await client.session('chat-evidence-session', { + metadata: {}, + }) + + await session.addPeers([peer.id]) + await session.addMessages([peer.message('I enjoy hiking')]) + + const result = await peer.chat('What does this user enjoy?', { + includeEvidence: true, + }) + + // The answer is wrapped rather than returned bare, and evidence is + // present even when the run read nothing -- empty is not absent. + expect(result).toHaveProperty('content') + expect(result).toHaveProperty('evidence') + expect(result.evidence).not.toBeNull() + expect(Array.isArray(result.evidence?.conclusions)).toBe(true) + expect(Array.isArray(result.evidence?.messages)).toBe(true) + expect(Array.isArray(result.evidence?.tool_calls)).toBe(true) + }) + + test('chat without includeEvidence still returns a bare answer', async () => { + const peer = await client.peer('chat-no-evidence-peer') + + const response = await peer.chat('What does this user enjoy?') + + expect(response === null || typeof response === 'string').toBe(true) + }) + test('chat with session scope', async () => { const peer = await client.peer('chat-session-peer') const session = await client.session('chat-scoped-session', { metadata: {} }) diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 96cf57c8..d834bfc4 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -10,6 +10,7 @@ import { Peer } from './peer' import { Scope } from './scope' import { Session } from './session' import type { + ChatResponse, MessageResponse, PageResponse, PeerResponse, @@ -998,13 +999,33 @@ export class Honcho { * @param options.responseFormat - Optional JSON Schema (root type "object") the response * must conform to. When provided, the response content is a * JSON string matching this schema. - * @returns Promise resolving to the response string, or null if no relevant information + * @param options.includeEvidence - When true, resolves to a ChatResponse carrying the + * answer alongside what the dialectic read to produce it. + * Evidence is collated from the agent's own reads rather + * than reported by the model, so it is broader than a + * citation list. + * @returns Promise resolving to the response string, or null if no relevant information. + * With includeEvidence, a ChatResponse wrapping that content plus its evidence. * * @example * ```typescript * const response = await honcho.chat('What are common themes across all users?') + * + * const { content, evidence } = await honcho.chat('What are common themes?', { + * includeEvidence: true, + * }) * ``` */ + async chat( + query: string, + options: { + session?: string | Session + reasoningLevel?: ReasoningLevel + responseFormat?: Record + scope?: string | string[] + includeEvidence: true + } + ): Promise> async chat( query: string, options?: { @@ -1012,8 +1033,19 @@ export class Honcho { reasoningLevel?: ReasoningLevel responseFormat?: Record scope?: string | string[] + includeEvidence?: false } - ): Promise { + ): Promise + async chat( + query: string, + options?: { + session?: string | Session + reasoningLevel?: ReasoningLevel + responseFormat?: Record + scope?: string | string[] + includeEvidence?: boolean + } + ): Promise | string | null> { const validatedQuery = SearchQuerySchema.parse(query) const resolvedSessionId = options?.session ? resolveId(options.session) @@ -1026,11 +1058,13 @@ export class Honcho { reasoning_level: options?.reasoningLevel, response_format: options?.responseFormat, scope: options?.scope, + include_evidence: options?.includeEvidence ? true : undefined, }) - if (!response.content) { - return null + const content = response.content || null + if (!options?.includeEvidence) { + return content } - return response.content + return { content, evidence: response.evidence ?? null } } /** @@ -1048,6 +1082,10 @@ export class Honcho { * @param options.responseFormat - Optional JSON Schema (root type "object") the response * must conform to. When provided, the response content is a * JSON string matching this schema. + * @param options.includeEvidence - When true, the returned stream's `evidence` is + * populated once it has been fully consumed. It cannot + * be known before then, so the server sends it on the + * terminal chunk. * @returns Promise resolving to a DialecticStreamResponse that can be iterated over * * @example @@ -1065,6 +1103,7 @@ export class Honcho { reasoningLevel?: ReasoningLevel responseFormat?: Record scope?: string | string[] + includeEvidence?: boolean } ): Promise { const validatedQuery = SearchQuerySchema.parse(query) @@ -1078,6 +1117,7 @@ export class Honcho { reasoning_level: options?.reasoningLevel, response_format: options?.responseFormat, scope: options?.scope, + include_evidence: options?.includeEvidence ? true : undefined, }) return createDialecticStream(response) diff --git a/sdks/typescript/src/http/streaming.ts b/sdks/typescript/src/http/streaming.ts index 92901c4a..b667ad56 100644 --- a/sdks/typescript/src/http/streaming.ts +++ b/sdks/typescript/src/http/streaming.ts @@ -1,3 +1,5 @@ +import type { Evidence } from '../types/api' + /** * Parse Server-Sent Events from a Response body. * @@ -64,6 +66,8 @@ export interface DialecticStreamChunk { delta: { content?: string } + /** Set only on the terminal chunk, and only when it was requested. */ + evidence?: Evidence | null } /** @@ -76,9 +80,25 @@ export class DialecticStreamResponse implements AsyncIterable { private generator: AsyncGenerator private chunks: string[] = [] private consumed = false + private evidenceSource?: () => Evidence | null - constructor(generator: AsyncGenerator) { + constructor( + generator: AsyncGenerator, + evidenceSource?: () => Evidence | null + ) { this.generator = generator + this.evidenceSource = evidenceSource + } + + /** + * What the answer was built from, once the stream has finished. + * + * The server can only know this after the answer is complete, so it arrives + * on the stream's terminal chunk. Reading it before the stream is fully + * consumed returns null, as does a request that did not ask for evidence. + */ + get evidence(): Evidence | null { + return this.evidenceSource?.() ?? null } /** @@ -131,9 +151,13 @@ export class DialecticStreamResponse implements AsyncIterable { export function createDialecticStream( response: Response ): DialecticStreamResponse { + // Captured from the terminal chunk, which is otherwise discarded. + let evidence: Evidence | null = null + async function* streamContent(): AsyncGenerator { for await (const chunk of parseSSE(response)) { if (chunk.done) { + evidence = chunk.evidence ?? null return } const content = chunk.delta?.content @@ -143,5 +167,5 @@ export function createDialecticStream( } } - return new DialecticStreamResponse(streamContent()) + return new DialecticStreamResponse(streamContent(), () => evidence) } diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 509ba645..3e7510df 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -51,9 +51,14 @@ export { // API types (snake_case, for advanced usage) export type { + ChatResponse, ConclusionLevel, ConclusionQueryParams, ConclusionResponse, + Evidence, + EvidenceMessageRef, + EvidenceObservation, + EvidenceToolCall, MessageResponse, PageResponse, PeerContextResponse, diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index 9dab1658..d9a6499b 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -13,6 +13,7 @@ import { Page } from './pagination' import type { Scope } from './scope' import { Session } from './session' import type { + ChatResponse, MessageResponse, PageResponse, PeerCardResponse, @@ -259,6 +260,7 @@ export class Peer { filters?: Record reasoning_level?: string response_format?: Record + include_evidence?: boolean }): Promise { await this._ensureWorkspace() return this._http.post( @@ -275,6 +277,7 @@ export class Peer { filters?: Record reasoning_level?: string response_format?: Record + include_evidence?: boolean }): Promise { await this._ensureWorkspace() return this._http.stream( @@ -422,8 +425,33 @@ export class Peer { sessions?: (string | Session)[] reasoningLevel?: string responseFormat: ZodType + includeEvidence: true + } + ): Promise> + async chat( + query: string, + options: { + target?: string | Peer + session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] + reasoningLevel?: string + responseFormat: ZodType + includeEvidence?: false } ): Promise + async chat( + query: string, + options: { + target?: string | Peer + session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] + reasoningLevel?: string + responseFormat?: Record + includeEvidence: true + } + ): Promise> async chat( query: string, options?: { @@ -433,6 +461,7 @@ export class Peer { sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: Record + includeEvidence?: false } ): Promise async chat( @@ -444,8 +473,9 @@ export class Peer { sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: ZodType | Record + includeEvidence?: boolean } - ): Promise { + ): Promise | T | string | null> { const targetId = options?.target ? typeof options.target === 'string' ? options.target @@ -465,6 +495,7 @@ export class Peer { sessions: options?.sessions, reasoningLevel: options?.reasoningLevel, responseFormat: options?.responseFormat, + includeEvidence: options?.includeEvidence, }) const zodSchema = @@ -480,14 +511,20 @@ export class Peer { ...scopeRecallFields(chatParams), reasoning_level: chatParams.reasoningLevel, response_format: Peer.toResponseFormatSchema(options?.responseFormat), + include_evidence: chatParams.includeEvidence ? true : undefined, }) - if (!response.content) { - return null + + // An empty answer stays null either way, so evidence is still available + // for a run that found nothing to say. + const content: T | string | null = response.content + ? zodSchema + ? (zodSchema.parse(JSON.parse(response.content)) as T) + : response.content + : null + if (!chatParams.includeEvidence) { + return content } - if (zodSchema) { - return zodSchema.parse(JSON.parse(response.content)) - } - return response.content + return { content, evidence: response.evidence ?? null } } /** @@ -511,6 +548,10 @@ export class Peer { * See {@link Peer.chat} for the depth caveat. * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", * "high", or "max". Defaults to "low" if not provided. + * @param options.includeEvidence - When true, the returned stream's `evidence` is + * populated once it has been fully consumed. Evidence + * cannot be known before the answer is complete, so the + * server sends it on the stream's terminal chunk. * @returns Promise resolving to a DialecticStreamResponse that can be iterated over * * @example @@ -537,6 +578,7 @@ export class Peer { sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: ZodType | Record + includeEvidence?: boolean } ): Promise { const targetId = options?.target @@ -558,6 +600,7 @@ export class Peer { sessions: options?.sessions, reasoningLevel: options?.reasoningLevel, responseFormat: options?.responseFormat, + includeEvidence: options?.includeEvidence, }) const response = await this._chatStream({ @@ -567,6 +610,7 @@ export class Peer { ...scopeRecallFields(chatParams), reasoning_level: chatParams.reasoningLevel, response_format: Peer.toResponseFormatSchema(options?.responseFormat), + include_evidence: chatParams.includeEvidence ? true : undefined, }) return createDialecticStream(response) diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index c5855713..c748fad6 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -75,10 +75,52 @@ export interface PeerChatParams { target?: string reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max' response_format?: Record + include_evidence?: boolean +} + +/** A conclusion the dialectic read while answering. */ +export interface EvidenceObservation { + id: string + level: 'explicit' | 'deductive' | 'inductive' | 'contradiction' + content: string + created_at: string + session_id: string | null + /** Conclusions this one was derived from; empty for explicit conclusions. */ + source_ids: string[] +} + +/** A message the dialectic read while answering. */ +export interface EvidenceMessageRef { + id: string + session_id: string + peer_id: string + content_preview: string + created_at: string +} + +/** A tool the dialectic invoked while answering. */ +export interface EvidenceToolCall { + tool_name: string + tool_input: Record +} + +/** + * What the dialectic read and did while answering. + * + * Collated from what the agent accessed rather than reported by the model, so + * it over-reports: a listed conclusion was read, which is not proof the answer + * leaned on it. `toolCalls` omits results and failed calls. + */ +export interface Evidence { + conclusions: EvidenceObservation[] + messages: EvidenceMessageRef[] + tool_calls: EvidenceToolCall[] + reasoning_trace_id: string | null } export interface PeerChatResponse { content: string | null + evidence?: Evidence | null } export interface WorkspaceChatParams { @@ -88,10 +130,23 @@ export interface WorkspaceChatParams { reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max' response_format?: Record scope?: string | string[] + include_evidence?: boolean } export interface WorkspaceChatResponse { content: string | null + evidence?: Evidence | null +} + +/** + * An answer together with what it was built from. + * + * Returned by `chat` when `includeEvidence` is set; without it, `chat` returns + * the answer on its own. + */ +export interface ChatResponse { + content: TContent | null + evidence: Evidence | null } export interface PeerRepresentationParams { diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index 832690e2..f45bffdc 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -514,6 +514,7 @@ export const ChatQuerySchema = z responseFormat: z .union([z.instanceof(z.ZodType), z.record(z.string(), z.unknown())]) .optional(), + includeEvidence: z.boolean().optional(), }) .strict() .superRefine(scopeExclusivityIssues) From 423eb3f54a4ecc9b2da487452650d6fca0e47507 Mon Sep 17 00:00:00 2001 From: Eugene Eisenstein Date: Thu, 3 Sep 2026 16:44:08 -0400 Subject: [PATCH 2/2] refactor(sdk): drop message content from TypeScript evidence types Follows the server: `EvidenceMessageRef` reports identity and provenance, not content. Callers fetch a message by id when they need its text. Co-Authored-By: Claude Opus 5 (1M context) --- sdks/typescript/__tests__/evidence.unit.test.ts | 1 - sdks/typescript/src/types/api.ts | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sdks/typescript/__tests__/evidence.unit.test.ts b/sdks/typescript/__tests__/evidence.unit.test.ts index 8b6711e1..db50b92c 100644 --- a/sdks/typescript/__tests__/evidence.unit.test.ts +++ b/sdks/typescript/__tests__/evidence.unit.test.ts @@ -26,7 +26,6 @@ const EVIDENCE: Evidence = { id: 'msg-sentinel', session_id: 'session-1', peer_id: 'alice', - content_preview: 'I drink a lot of coffee', created_at: '2026-01-01T00:00:00Z', }, ], diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index c748fad6..913dc49d 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -89,12 +89,17 @@ export interface EvidenceObservation { source_ids: string[] } -/** A message the dialectic read while answering. */ +/** + * A message the dialectic read while answering. + * + * Identity and provenance only — no content. Fetch the message by `id` when + * you need its text; evidence is for auditing what was read, not for reading + * messages in bulk. + */ export interface EvidenceMessageRef { id: string session_id: string peer_id: string - content_preview: string created_at: string }