project-nomad/admin/tests/unit/ollama_controller_chat.spec.ts
Chris Sherwood 0a93e7de80 test(ai): run the controller vision spec under Japa
It constructs OllamaController, which imports the Adonis logger service at
module scope, so it cannot run under the plain node:test runner. It failed
identically on the original branch -- the coverage has never executed. Moving
it to the Japa runner puts it in the same bucket as the other app-importing
specs, where a booted app is available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00

351 lines
10 KiB
TypeScript

/**
* Controller-level vision behaviour (#1176).
*
* A Japa spec, not a plain `node:test` file: it constructs OllamaController,
* which imports '@adonisjs/core/services/logger' at module scope and therefore
* needs a booted app. Run with `node ace test unit`, not `npm run test:unit` --
* the latter is the plain-runner path used by the pure-function specs.
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from '@japa/runner'
import sharp from 'sharp'
import OllamaController from '../../app/controllers/ollama_controller.js'
class FakeSseResponse extends EventEmitter {
chunks: string[] = []
ended = false
setHeader() {}
flushHeaders() {}
write(chunk: string) {
this.chunks.push(chunk)
}
end() {
this.ended = true
}
}
test('unknown backend image rejection returns actionable compatibility details over SSE', async () => {
const directory = await mkdtemp(join(tmpdir(), 'nomad-controller-image-'))
const imagePath = join(directory, 'sample.png')
const image = await sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: '#556b2f',
},
})
.png()
.toBuffer()
await writeFile(imagePath, image)
const rawResponse = new FakeSseResponse()
const payload = {
model: 'openai-compatible-model',
messages: [{ role: 'user', content: 'What is shown?' }],
stream: true,
}
const request = {
files: () => [
{
tmpPath: imagePath,
size: image.byteLength,
clientName: 'sample.png',
isValid: true,
},
],
input: (name: string, fallback?: unknown) =>
name === 'payload' ? JSON.stringify(payload) : fallback,
}
const response = {
response: rawResponse,
status: () => response,
send: () => undefined,
}
const ollamaService = {
getModelCapabilities: async () => ({ thinking: false, vision: 'unknown' as const }),
chatStream: async () => {
throw new Error('The upstream backend rejected image_url content.')
},
}
const controller = new OllamaController(
{} as any,
{} as any,
ollamaService as any,
// dev moved prompt assembly, NOMAD.md injection and retrieval into
// RagPipelineService after this test was written. The stub returns the
// messages untouched so these cases still exercise only the vision paths.
{
buildPrompt: async (messages: unknown) => ({
messages,
numCtx: undefined,
numPredict: undefined,
retrieved: [],
injected: [],
}),
} as any,
{
hasDocuments: async () => false,
searchSimilarDocuments: async () => [],
} as any,
{ record: async () => undefined } as any
)
try {
await controller.chat({ request, response } as any)
} finally {
await rm(directory, { recursive: true, force: true })
}
assert.equal(rawResponse.ended, true)
assert.equal(rawResponse.chunks.length, 1)
const event = JSON.parse(rawResponse.chunks[0].replace(/^data: /, '').trim())
assert.equal(event.error, true)
assert.match(event.message, /cannot confirm/i)
assert.match(event.message, /supports images/i)
assert.doesNotMatch(event.message, /backend|metadata|projector/i)
})
test('unknown backend image rejection returns actionable compatibility details without streaming', async () => {
const directory = await mkdtemp(join(tmpdir(), 'nomad-controller-image-'))
const imagePath = join(directory, 'sample.png')
const image = await sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: '#556b2f',
},
})
.png()
.toBuffer()
await writeFile(imagePath, image)
const payload = {
model: 'openai-compatible-model',
messages: [{ role: 'user', content: 'What is shown?' }],
stream: false,
}
const request = {
files: () => [
{
tmpPath: imagePath,
size: image.byteLength,
clientName: 'sample.png',
isValid: true,
},
],
input: (name: string, fallback?: unknown) =>
name === 'payload' ? JSON.stringify(payload) : fallback,
}
let statusCode: number | undefined
let responseBody: unknown
const response = {
response: new FakeSseResponse(),
status: (code: number) => {
statusCode = code
return response
},
send: (body: unknown) => {
responseBody = body
return body
},
}
const ollamaService = {
getModelCapabilities: async () => ({ thinking: false, vision: 'unknown' as const }),
chat: async () => {
throw new Error('The upstream backend rejected image_url content.')
},
}
const controller = new OllamaController(
{} as any,
{} as any,
ollamaService as any,
// dev moved prompt assembly, NOMAD.md injection and retrieval into
// RagPipelineService after this test was written. The stub returns the
// messages untouched so these cases still exercise only the vision paths.
{
buildPrompt: async (messages: unknown) => ({
messages,
numCtx: undefined,
numPredict: undefined,
retrieved: [],
injected: [],
}),
} as any,
{
hasDocuments: async () => false,
searchSimilarDocuments: async () => [],
} as any,
{ record: async () => undefined } as any
)
try {
await controller.chat({ request, response } as any)
} finally {
await rm(directory, { recursive: true, force: true })
}
assert.equal(statusCode, 422)
assert.match((responseBody as { message: string }).message, /cannot confirm/i)
assert.match((responseBody as { message: string }).message, /supports images/i)
assert.doesNotMatch(
(responseBody as { message: string }).message,
/backend|metadata|projector/i
)
})
test('unknown backend image requests do not mislabel preprocessing failures as incompatibility', async () => {
const directory = await mkdtemp(join(tmpdir(), 'nomad-controller-image-'))
const imagePath = join(directory, 'sample.png')
const image = await sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: '#556b2f',
},
})
.png()
.toBuffer()
await writeFile(imagePath, image)
const rawResponse = new FakeSseResponse()
const payload = {
model: 'openai-compatible-model',
messages: [{ role: 'user', content: 'What is shown?' }],
stream: true,
}
const request = {
files: () => [
{
tmpPath: imagePath,
size: image.byteLength,
clientName: 'sample.png',
isValid: true,
},
],
input: (name: string, fallback?: unknown) =>
name === 'payload' ? JSON.stringify(payload) : fallback,
}
const response = {
response: rawResponse,
status: () => response,
send: () => undefined,
}
let inferenceCalled = false
const ollamaService = {
getModelCapabilities: async () => ({ thinking: false, vision: 'unknown' as const }),
chatStream: async () => {
inferenceCalled = true
throw new Error('Inference should not be reached')
},
}
const controller = new OllamaController(
{} as any,
{} as any,
ollamaService as any,
// Prompt assembly (NOMAD.md included) now lives in RagPipelineService, so
// that is where this failure originates. The assertion is unchanged and is
// the one that matters: a failure before inference must not reach the model,
// and must still close the stream with a single error event.
{
buildPrompt: async () => {
throw new Error('Prompt assembly unavailable')
},
} as any,
{
hasDocuments: async () => false,
searchSimilarDocuments: async () => [],
} as any,
{ record: async () => undefined } as any
)
try {
await controller.chat({ request, response } as any)
} finally {
await rm(directory, { recursive: true, force: true })
}
assert.equal(inferenceCalled, false)
assert.equal(rawResponse.ended, true)
assert.equal(rawResponse.chunks.length, 1)
const event = JSON.parse(rawResponse.chunks[0].replace(/^data: /, '').trim())
assert.deepEqual(event, { error: true })
})
test('text-only JSON chat preserves long histories through the controller boundary', async () => {
const messages = Array.from({ length: 201 }, (_, index) => ({
role: 'user' as const,
content: `Message ${index}`,
}))
const payload = {
model: 'known-text-model',
messages,
stream: false,
}
const request = {
files: () => [],
input: (name: string, fallback?: unknown) =>
name === 'collection' ? 'A'.repeat(500) : fallback,
validateUsing: (schema: { validate(value: unknown): Promise<unknown> }) =>
schema.validate(payload),
}
const response = {
response: new FakeSseResponse(),
status: () => response,
send: () => undefined,
}
let upstreamMessages: unknown[] = []
const ollamaService = {
getModelCapabilities: async () => ({ thinking: false, vision: 'unsupported' as const }),
chat: async (chatRequest: { messages: unknown[] }) => {
upstreamMessages = chatRequest.messages
return {
message: { content: 'Text response' },
done: true,
model: 'known-text-model',
}
},
}
const controller = new OllamaController(
{} as any,
{} as any,
ollamaService as any,
// dev moved prompt assembly, NOMAD.md injection and retrieval into
// RagPipelineService after this test was written. The stub returns the
// messages untouched so these cases still exercise only the vision paths.
{
buildPrompt: async (messages: unknown) => ({
messages,
numCtx: undefined,
numPredict: undefined,
retrieved: [],
injected: [],
}),
} as any,
{
hasDocuments: async () => false,
searchSimilarDocuments: async () => [],
} as any,
{ record: async () => undefined } as any
)
const result = await controller.chat({ request, response } as any)
assert.ok(result)
assert.equal(result.message.content, 'Text response')
assert.equal(upstreamMessages.length, 202)
assert.deepEqual(upstreamMessages.slice(1), messages)
assert.ok(
upstreamMessages.every(
(message) => typeof (message as { content?: unknown }).content === 'string'
)
)
})