11import { describe , test , expect } from "bun:test" ;
22import { MetaDataLoader } from "@metaobjectsdev/metadata" ;
3- import type { MetaRoot , MetaObject } from "@metaobjectsdev/metadata" ;
4- import { Format } from "@metaobjectsdev/render" ;
3+ import type { MetaRoot } from "@metaobjectsdev/metadata" ;
54import { ObjectManager } from "../src/object-manager.js" ;
65import { inMemoryDriver } from "../src/drivers/in-memory-driver.js" ;
76import {
87 NullRecorder ,
98 LlmCallDbRecorder ,
109 recordLlmCall ,
10+ buildLlmCallRow ,
1111 type LlmCallRow ,
12+ type LlmCallInput ,
1213} from "../src/llm-recorder.js" ;
1314
1415// =============================================================================
@@ -36,6 +37,7 @@ const META_JSON = {
3637 { "field.string" : { name : "parentSpanId" } } ,
3738 { "field.string" : { name : "sessionId" } } ,
3839 { "field.string" : { name : "callType" } } ,
40+ { "field.string" : { name : "system" } } ,
3941 { "field.string" : { name : "requestModel" } } ,
4042 { "field.string" : { name : "responseModel" } } ,
4143 { "field.long" : { name : "inputTokens" } } ,
@@ -50,6 +52,8 @@ const META_JSON = {
5052 // ObjectManager writes the JSON.stringify'd string; in-memory driver
5153 // keeps it as a string on read-back.
5254 { "field.string" : { name : "llmRequest" , "@dbColumnType" : "jsonb" } } ,
55+ // llmResponse: raw response body as jsonb string (matches LlmCallBase).
56+ { "field.string" : { name : "llmResponse" , "@dbColumnType" : "jsonb" } } ,
5357 // voResponse: typed VO stored as jsonb via @objectRef + @storage.
5458 // ObjectManager validates the object against VerdictResponse and stores it.
5559 { "field.object" : { name : "voResponse" , "@objectRef" : "VerdictResponse" , "@storage" : "jsonb" } } ,
@@ -133,13 +137,11 @@ describe("LlmCallDbRecorder", () => {
133137// Task 2 — recordLlmCall
134138// =============================================================================
135139
136- describe ( "recordLlmCall" , ( ) => {
137- test ( "good response → status ok, voResponse populated, row persisted " , async ( ) => {
140+ describe ( "recordLlmCall (generic base-row path) " , ( ) => {
141+ test ( "status ok → base row persisted (raw llmRequest/llmResponse, no voResponse) " , async ( ) => {
138142 const root = await loadFixture ( ) ;
139143 const om = makeOm ( root ) ;
140144 const recorder = new LlmCallDbRecorder ( om , "TraceCall" ) ;
141- const verdictMo = root . findObject ( "VerdictResponse" ) as MetaObject ;
142- expect ( verdictMo ) . toBeDefined ( ) ;
143145
144146 const result = await recordLlmCall (
145147 {
@@ -149,29 +151,34 @@ describe("recordLlmCall", () => {
149151 startedAt : "2026-01-01T00:00:00Z" ,
150152 llmRequest : { prompt : "hello" } ,
151153 llmResponseText : '{"verdict":"approve","score":90}' ,
154+ status : "ok" ,
155+ errorDetail : null ,
152156 } ,
153- { recorder, responseMo : verdictMo , format : Format . JSON } ,
157+ { recorder } ,
154158 ) ;
155159
160+ // recordLlmCall echoes the caller-supplied outcome; it does not extract.
156161 expect ( result . status ) . toBe ( "ok" ) ;
157162 expect ( result . errorDetail ) . toBeNull ( ) ;
158- expect ( result . voResponse ) . toEqual ( { verdict : "approve" , score : 90 } ) ;
159163
160- // Row must have been persisted
164+ // Row must have been persisted with the raw envelope + I/O.
161165 const row = await om . findById ( "TraceCall" , 1 ) ;
162166 expect ( row ) . not . toBeNull ( ) ;
163167 expect ( row ! . spanId ) . toBe ( "span-ok" ) ;
164168 expect ( row ! . status ) . toBe ( "ok" ) ;
165- expect ( row ! . voResponse ) . toEqual ( { verdict : "approve" , score : 90 } ) ;
169+ // The generic path never writes voResponse — TraceCall declares it nullable,
170+ // so it stays absent/null in the stored row.
171+ expect ( row ! . voResponse ?? null ) . toBeNull ( ) ;
166172 // In-memory driver stores llmRequest as the JSON string (field.string + @dbColumnType jsonb).
167173 expect ( JSON . parse ( row ! . llmRequest as string ) ) . toEqual ( { prompt : "hello" } ) ;
174+ // llmResponse stores the JSON-encoded raw response text.
175+ expect ( JSON . parse ( row ! . llmResponse as string ) ) . toBe ( '{"verdict":"approve","score":90}' ) ;
168176 } ) ;
169177
170- test ( "bad response (missing required verdict) → status error, voResponse null, row still persisted " , async ( ) => {
178+ test ( "caller-supplied error outcome is threaded onto the persisted row" , async ( ) => {
171179 const root = await loadFixture ( ) ;
172180 const om = makeOm ( root ) ;
173181 const recorder = new LlmCallDbRecorder ( om , "TraceCall" ) ;
174- const verdictMo = root . findObject ( "VerdictResponse" ) as MetaObject ;
175182
176183 const result = await recordLlmCall (
177184 {
@@ -181,45 +188,28 @@ describe("recordLlmCall", () => {
181188 startedAt : "2026-01-01T00:00:00Z" ,
182189 llmRequest : { prompt : "hello" } ,
183190 llmResponseText : '{"score":50}' ,
191+ status : "error" ,
192+ errorDetail : "lost required: verdict" ,
184193 } ,
185- { recorder, responseMo : verdictMo , format : Format . JSON } ,
194+ { recorder } ,
186195 ) ;
187196
188197 expect ( result . status ) . toBe ( "error" ) ;
189- expect ( result . voResponse ) . toBeNull ( ) ;
190- expect ( typeof result . errorDetail ) . toBe ( "string" ) ;
191- expect ( result . errorDetail ) . toContain ( "verdict" ) ;
198+ expect ( result . errorDetail ) . toBe ( "lost required: verdict" ) ;
192199
193- // Row must STILL have been persisted (failure-resilient contract)
200+ // Row must STILL have been persisted (every call is observable).
194201 const row = await om . findById ( "TraceCall" , 1 ) ;
195202 expect ( row ) . not . toBeNull ( ) ;
196203 expect ( row ! . spanId ) . toBe ( "span-bad" ) ;
197204 expect ( row ! . status ) . toBe ( "error" ) ;
198- expect ( row ! . voResponse ) . toBeNull ( ) ;
199- expect ( typeof row ! . errorDetail ) . toBe ( "string" ) ;
205+ expect ( row ! . errorDetail ) . toBe ( "lost required: verdict" ) ;
200206 } ) ;
201207} ) ;
202208
203209// =============================================================================
204210// Task 4 — parentSpanId + sessionId envelope fields
205211// =============================================================================
206212
207- // Minimal metadata: a value object with one required string field.
208- // Mirrors the VerdictResponse shape used in the integration test.
209- const ENVELOPE_META = JSON . stringify ( {
210- "metadata.root" : {
211- package : "test::ai" ,
212- children : [
213- {
214- "object.value" : {
215- name : "Resp" ,
216- children : [ { "field.string" : { name : "verdict" , "@required" : true } } ] ,
217- } ,
218- } ,
219- ] ,
220- } ,
221- } ) ;
222-
223213// A capturing recorder that stores the last row written by recordLlmCall.
224214class CaptureRecorder extends NullRecorder {
225215 last : LlmCallRow | null = null ;
@@ -228,16 +218,9 @@ class CaptureRecorder extends NullRecorder {
228218 }
229219}
230220
231- async function loadRespMo ( ) {
232- const res = await MetaDataLoader . fromString ( ENVELOPE_META , "json" ) ;
233- expect ( res . errors ) . toEqual ( [ ] ) ;
234- return res . root . findObject ( "Resp" ) ! ;
235- }
236-
237221describe ( "recordLlmCall envelope fields — parentSpanId + sessionId" , ( ) => {
238222 test ( "threads parentSpanId + sessionId into the persisted row" , async ( ) => {
239223 const rec = new CaptureRecorder ( ) ;
240- const responseMo = await loadRespMo ( ) ;
241224 await recordLlmCall (
242225 {
243226 spanId : "s1" ,
@@ -248,16 +231,17 @@ describe("recordLlmCall envelope fields — parentSpanId + sessionId", () => {
248231 startedAt : "2026-06-05T00:00:00Z" ,
249232 llmRequest : { a : 1 } ,
250233 llmResponseText : JSON . stringify ( { verdict : "ok" } ) ,
234+ status : "ok" ,
235+ errorDetail : null ,
251236 } ,
252- { recorder : rec , responseMo } ,
237+ { recorder : rec } ,
253238 ) ;
254239 expect ( rec . last ?. parentSpanId ) . toBe ( "p1" ) ;
255240 expect ( rec . last ?. sessionId ) . toBe ( "sess1" ) ;
256241 } ) ;
257242
258243 test ( "omitted parentSpanId + sessionId default to null in the row" , async ( ) => {
259244 const rec = new CaptureRecorder ( ) ;
260- const responseMo = await loadRespMo ( ) ;
261245 await recordLlmCall (
262246 {
263247 spanId : "s2" ,
@@ -266,10 +250,36 @@ describe("recordLlmCall envelope fields — parentSpanId + sessionId", () => {
266250 startedAt : "2026-06-05T00:00:00Z" ,
267251 llmRequest : { } ,
268252 llmResponseText : JSON . stringify ( { verdict : "ok" } ) ,
253+ status : "ok" ,
254+ errorDetail : null ,
269255 } ,
270- { recorder : rec , responseMo } ,
256+ { recorder : rec } ,
271257 ) ;
272258 expect ( rec . last ?. parentSpanId ) . toBeNull ( ) ;
273259 expect ( rec . last ?. sessionId ) . toBeNull ( ) ;
274260 } ) ;
275261} ) ;
262+
263+ // =============================================================================
264+ // Task 1 (P0) — buildLlmCallRow: base-row factory (exactly LlmCallBase fields)
265+ // =============================================================================
266+
267+ const BASE_INPUT : LlmCallInput = {
268+ spanId : "s" , traceId : "t" , callType : "X" ,
269+ startedAt : "2026-06-06T00:00:00Z" ,
270+ llmRequest : { q : 1 } , llmResponseText : '{"a":1}' ,
271+ status : "ok" , errorDetail : null ,
272+ } ;
273+
274+ describe ( "buildLlmCallRow" , ( ) => {
275+ test ( "row keys exactly match LlmCallBase's 18 fields (no voResponse; with llmResponse + system)" , ( ) => {
276+ const row = buildLlmCallRow ( { ...BASE_INPUT , system : "anthropic" } ) ;
277+ const base = [ "traceId" , "spanId" , "parentSpanId" , "sessionId" , "callType" , "system" ,
278+ "requestModel" , "responseModel" , "inputTokens" , "outputTokens" , "costMinor" ,
279+ "latencyMs" , "finishReason" , "status" , "errorDetail" , "startedAt" , "llmRequest" , "llmResponse" ] ;
280+ expect ( Object . keys ( row ) . sort ( ) ) . toEqual ( [ ...base ] . sort ( ) ) ;
281+ expect ( "voResponse" in row ) . toBe ( false ) ;
282+ expect ( row . system ) . toBe ( "anthropic" ) ;
283+ expect ( row . llmRequest ) . toBe ( '{"q":1}' ) ;
284+ } ) ;
285+ } ) ;
0 commit comments