mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
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
351 lines
10 KiB
TypeScript
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'
|
|
)
|
|
)
|
|
})
|