285 lines
26 KiB
TypeScript
285 lines
26 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import fs, { mkdir, mkdtemp, readFile, realpath, stat, symlink, writeFile, rm } from 'node:fs/promises'
|
|
import { syncBuiltinESMExports } from 'node:module'
|
|
import { mock } from 'node:test'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { createConversionContext } from '../src/main/model-bridge/history'
|
|
import { createServer } from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { acquireModelBridge } from '../src/main/model-bridge'
|
|
import { convertRequest, convertResponse, type ConversionContext } from '../src/main/model-bridge/protocol'
|
|
import { readSSE } from '../src/main/model-bridge/stream'
|
|
import type { ModelApi, ResolvedModel } from '../src/shared/models'
|
|
|
|
let checks = 0
|
|
function check(value: unknown, message: string) { assert.ok(value, message); checks++ }
|
|
const context = (): ConversionContext => ({ responsesHistory: new Map() })
|
|
function model(api: ModelApi, baseUrl = 'http://127.0.0.1:1/v1'): ResolvedModel {
|
|
return { profile: { id: api, model: 'offline-model', name: 'Offline', providerId: 'offline', baseUrl, hasApiKey: false, keyStorage: 'none', supportsImages: true, supportsToolCall: true }, config: { api, model: 'offline-model', baseUrl, apiKey: 'mock-upstream-secret', temperature: 0.2, maxToolRounds: 3 } }
|
|
}
|
|
const request = { model: 'attacker-model', max_tokens: 128, system: [{ type: 'text', text: 'System' }], messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGk=' } }] }, { role: 'assistant', content: [{ type: 'tool_use', id: 'call_1', name: 'echo', input: { value: 'ok' } }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }, { type: 'text', text: 'continue' }] }], tools: [{ name: 'echo', input_schema: { type: 'object', properties: { value: { type: 'string' } } } }], tool_choice: { type: 'any', disable_parallel_tool_use: true } }
|
|
for (const api of ['openai-completions', 'openai-responses'] as const) {
|
|
const value = convertRequest(request, model(api), context())
|
|
check(value.model === 'offline-model', `${api}: immutable model`)
|
|
check(value.tool_choice === 'required' && value.parallel_tool_calls === false, `${api}: tool choice`)
|
|
check(!('apiKey' in value), `${api}: no credentials in payload`)
|
|
if (api === 'openai-responses') {
|
|
check(value.store === false && !value.previous_response_id, 'Responses stateless')
|
|
check(value.input.some((x: any) => x.type === 'function_call') && value.input.some((x: any) => x.type === 'function_call_output'), 'Responses full tool history')
|
|
check(value.input[1].content[1].image_url === 'data:image/png;base64,aGk=', 'Responses image')
|
|
} else {
|
|
check(value.messages[2].tool_calls[0].function.arguments === '{"value":"ok"}', 'Chat tool history')
|
|
check(value.messages[3].role === 'tool' && value.messages[4].role === 'user', 'Chat tool result order')
|
|
}
|
|
assert.throws(() => convertRequest({ ...request, messages: [{ role: 'user', content: [{ type: 'document' }] }] }, model(api), context()), /Unsupported/); checks++
|
|
assert.throws(() => convertRequest({ ...request, messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: [{ type: 'document' }] }] }] }, model(api), context()), /Only text and image tool results/); checks++
|
|
for (const context_management of [null, {}, { edits: [] }, { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] }, { edits: [{ type: 'clear_thinking_20251015', keep: { type: 'all' } }] }]) {
|
|
assert.deepEqual(convertRequest({ ...request, context_management }, model(api), context()), value); checks++
|
|
}
|
|
for (const context_management of [false, { unknown: true }, { edits: null }, { edits: {} }, { edits: [null] }, { edits: [{ type: 'clear_thinking_20251015' }] }, { edits: [{ type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 1 } }] }, { edits: [{ type: 'clear_tool_uses_20250919' }] }, { edits: [{ type: 'compact_20260112' }] }, { edits: [{ type: 'clear_thinking_20251015', keep: 'all', unknown: true }] }]) {
|
|
assert.throws(() => convertRequest({ ...request, context_management }, model(api), context()), /context_management/); checks++
|
|
}
|
|
const environment = convertRequest({ ...request, messages: [...request.messages, { role: 'system', content: [{ type: 'text', text: 'SDK environment', cache_control: { type: 'ephemeral' } }] }] }, model(api), context())
|
|
check((environment.input ?? environment.messages).at(-1).role === 'system' && (environment.input ?? environment.messages).at(-1).content[0].text === 'SDK environment', `${api}: SDK in-history system instruction preserved in order`)
|
|
assert.throws(() => convertRequest({ ...request, messages: [{ role: 'system', content: [{ type: 'image', source: { type: 'url', url: 'https://invalid.example/image.png' } }] }] }, model(api), context()), /Only text system blocks/); checks++
|
|
}
|
|
const chat = convertResponse({ id: 'chat_1', choices: [{ message: { content: 'ok', reasoning_content: 'reason', tool_calls: [{ id: 'call_1', function: { name: 'echo', arguments: '{"v":1}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 20, completion_tokens: 8, prompt_tokens_details: { cached_tokens: 5 } } }, 'm', false, context())
|
|
check(chat.content[0].thinking === 'reason' && !chat.content[0].signature && chat.stop_reason === 'tool_use', 'Chat reasoning and tools')
|
|
check(chat.usage.input_tokens === 15 && chat.usage.cache_read_input_tokens === 5 && chat.usage.output_tokens === 8, 'Usage cached semantics')
|
|
for (const api of ['openai-completions', 'openai-responses'] as const) {
|
|
const m = model(api); m.config.supportsReasoning = true; m.config.thinkingLevel = 'auto'
|
|
const auto = convertRequest(request, m, context())
|
|
check(!auto.reasoning_effort && !auto.reasoning?.effort, `${api}: auto effort is not forwarded`)
|
|
m.config.thinkingLevel = 'high'; m.profile.supportedEfforts = ['high']
|
|
const high = convertRequest(request, m, context())
|
|
check((high.reasoning?.effort ?? high.reasoning_effort) === 'high', `${api}: supported effort forwarded`)
|
|
m.config.thinkingLevel = 'low'
|
|
assert.throws(() => convertRequest(request, m, context()), /reasoning effort/); checks++
|
|
const images = convertRequest({ max_tokens: 30, messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'img', content: [{ type: 'text', text: 'screenshot' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGk=' } }] }] }] }, model(api), context())
|
|
const messages = images.input ?? images.messages
|
|
check(messages[0].output === 'screenshot' || messages[0].content === 'screenshot', `${api}: tool text preserved with images`)
|
|
check(JSON.stringify(messages[1]).includes('data:image/png;base64,aGk=') && JSON.stringify(messages[1]).includes('img'), `${api}: attributed tool image preserved`)
|
|
}
|
|
const rc = context(), responseData = { id: 'r1', status: 'completed', output: [{ type: 'reasoning', id: 'rs1', encrypted_content: 'opaque', summary: [{ type: 'summary_text', text: 'reason' }] }, { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo', arguments: '{"v":1}' }], usage: { input_tokens: 7, output_tokens: 4 } }
|
|
const converted = convertResponse(responseData, 'm', true, rc)
|
|
const replay = convertRequest({ max_tokens: 30, context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] }, messages: [{ role: 'assistant', content: converted.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }] }, model('openai-responses'), rc)
|
|
check(replay.input[0].encrypted_content === 'opaque' && replay.input[1].call_id === 'call_1', 'Responses opaque reasoning exact-history replay')
|
|
assert.throws(() => convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }] }, model('openai-responses'), context()), /history is not available/); checks++
|
|
rc.responsePrefix = undefined
|
|
convertResponse({ ...responseData, output: [{ ...responseData.output[0], encrypted_content: 'different' }, responseData.output[1]] }, 'm', true, rc)
|
|
assert.throws(() => convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }] }, model('openai-responses'), rc), /Ambiguous/); checks++
|
|
|
|
// Identical visible replies at different history occurrences must not share opaque state.
|
|
const occurrences = context(), history: any[] = [{ role: 'user', content: 'first' }]
|
|
const hiddenReply = (opaque: string) => ({ id: opaque, status: 'completed', output: [{ type: 'reasoning', id: opaque, summary: [], encrypted_content: opaque }, { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Done' }] }] })
|
|
convertRequest({ max_tokens: 20, messages: history }, model('openai-responses'), occurrences)
|
|
const firstDone = convertResponse(hiddenReply('opaque-first'), 'm', true, occurrences)
|
|
history.push({ role: 'assistant', content: firstDone.content }, { role: 'user', content: 'second' })
|
|
convertRequest({ max_tokens: 20, messages: history }, model('openai-responses'), occurrences)
|
|
const secondDone = convertResponse(hiddenReply('opaque-second'), 'm', true, occurrences)
|
|
history.push({ role: 'assistant', content: secondDone.content }, { role: 'user', content: 'third' })
|
|
const repeated = convertRequest({ max_tokens: 20, messages: history }, model('openai-responses'), occurrences)
|
|
check(repeated.input.filter((x: any) => x.type === 'reasoning').map((x: any) => x.encrypted_content).join(',') === 'opaque-first,opaque-second', 'Identical visible responses replay their own history occurrence')
|
|
assert.throws(() => convertRequest({ max_tokens: 20, messages: [{ role: 'assistant', content: firstDone.content }, { role: 'user', content: 'compacted' }] }, model('openai-responses'), occurrences), /compaction or fork/); checks++
|
|
|
|
// UTF-8/SSE fragmentation, CRLF and multiline data.
|
|
const encoded = new TextEncoder().encode('data: {"text":\r\ndata: "你好"}\r\n\r\ndata: [DONE]\n\n')
|
|
const parsed = []
|
|
for await (const value of readSSE(new ReadableStream({ start(c) { for (const byte of encoded) c.enqueue(new Uint8Array([byte])); c.close() } }))) parsed.push(value)
|
|
check((parsed[0] as any).text === '你好' && parsed[1] === '[DONE]', 'SSE fragmented UTF8 and CRLF')
|
|
|
|
let mode = 'normal', last: any, aborted = false, hits = 0
|
|
const upstream = createServer(async (req, res) => {
|
|
hits++; const chunks = []; for await (const chunk of req) chunks.push(chunk)
|
|
last = { path: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString()) }
|
|
if (mode === 'error') { res.writeHead(401); res.end('mock-upstream-secret'); return }
|
|
if (mode === 'redirect') { res.writeHead(307, { location: 'http://127.0.0.1:1/never' }); res.end(); return }
|
|
if (mode === 'cancel') { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.write(': waiting\n\n'); res.once('close', () => { aborted = true }); return }
|
|
const responses = req.url === '/v1/responses'
|
|
const completion = responses ? { id: 'r2', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '你好' }] }, { type: 'function_call', call_id: 'call_2', name: 'echo', arguments: '{"v":2}' }], usage: { input_tokens: 9, output_tokens: 3 } } : { id: 'c2', choices: [{ message: { content: '你好', tool_calls: [{ id: 'call_2', function: { name: 'echo', arguments: '{"v":2}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 9, completion_tokens: 3 } }
|
|
if (responses && ['hidden', 'reasoning'].includes(mode)) completion.output.push({ type: 'reasoning', id: 'rs2', encrypted_content: 'opaque-private-history', summary: mode === 'hidden' ? [] : [{ type: 'summary_text', text: 'one' }, { type: 'summary_text', text: 'two' }] } as any)
|
|
if (!last.body.stream) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(completion)); return }
|
|
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
const events = responses ? [
|
|
{ type: 'response.created', response: { id: 'r2' } },
|
|
{ type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'm1' } },
|
|
{ type: 'response.output_text.delta', output_index: 0, content_index: 0, delta: '你好' },
|
|
{ type: 'response.output_text.done', output_index: 0, content_index: 0, text: '你好' },
|
|
{ type: 'response.output_item.added', output_index: 1, item: { type: 'function_call', call_id: 'call_2', name: 'echo' } },
|
|
{ type: 'response.function_call_arguments.delta', output_index: 1, delta: '{"v":' },
|
|
{ type: 'response.function_call_arguments.delta', output_index: 1, delta: '2}' },
|
|
{ type: 'response.output_item.done', output_index: 1, item: completion.output[1] },
|
|
{ type: 'response.completed', response: completion }
|
|
] : [
|
|
{ choices: [{ index: 0, delta: { content: '你好' }, finish_reason: null }] },
|
|
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_2', function: { name: 'echo', arguments: '{"v":' } }] }, finish_reason: null }] },
|
|
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '2}' } }] }, finish_reason: 'tool_calls' }] },
|
|
{ choices: [], usage: completion.usage }
|
|
]
|
|
if (responses && mode === 'reasoning') events.splice(events.length - 1, 0,
|
|
{ type: 'response.output_item.added', output_index: 2, item: { type: 'reasoning', id: 'rs2' } } as any,
|
|
{ type: 'response.reasoning_summary_text.delta', output_index: 2, summary_index: 0, delta: 'one' } as any,
|
|
{ type: 'response.reasoning_summary_text.delta', output_index: 2, summary_index: 1, delta: 'two' } as any,
|
|
{ type: 'response.output_item.done', output_index: 2, item: completion.output[2] } as any)
|
|
if (responses) events.splice(events.length - 1, 0, {
|
|
type: 'response.metadata', sequence_number: 27, response_id: 'r2',
|
|
metadata: { openai_chatgpt_moderation_metadata: { prompt: {}, generation: {}, tool_call: {}, tool_response: {} } }
|
|
} as any)
|
|
if (responses && mode === 'mismatch') (events[2] as any).delta = 'truncated'
|
|
const wire = events.map(x => `data: ${JSON.stringify(x)}\r\n\r\n`).join('') + (responses ? '' : 'data: [DONE]\n\n')
|
|
const bytes = Buffer.from(mode === 'eof' ? wire.slice(0, wire.indexOf('data:', 10)) : wire)
|
|
for (let i = 0; i < bytes.length; i += 7) res.write(bytes.subarray(i, i + 7))
|
|
res.end()
|
|
})
|
|
await new Promise<void>(resolve => upstream.listen(0, '127.0.0.1', resolve))
|
|
const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`
|
|
try {
|
|
for (const api of ['openai-completions', 'openai-responses'] as const) {
|
|
const original = model(api, upstreamUrl), lease = await acquireModelBridge(original)
|
|
original.config.model = 'changed'; original.config.apiKey = 'changed'
|
|
const send = (body: any, path = '/v1/messages', token = lease.apiKey, signal?: AbortSignal) => fetch(lease.baseUrl + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': token }, body: JSON.stringify(body), signal })
|
|
try {
|
|
for (const stream of [false, true]) {
|
|
mode = 'normal'
|
|
const result = await send({ ...request, stream, context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] } })
|
|
check(result.status === 200, `${api}: HTTP ${stream ? 'stream' : 'json'}`)
|
|
check(!('context_management' in last.body), `${api}: SDK keep-all hint is not forwarded upstream`)
|
|
check(last.path === (api === 'openai-responses' ? '/v1/responses' : '/v1/chat/completions') && last.body.model === 'offline-model' && last.auth === 'Bearer mock-upstream-secret', `${api}: frozen route and credentials`)
|
|
if (stream) {
|
|
const frames: any[] = []; for await (const event of readSSE(result.body!)) frames.push(event)
|
|
check(frames[0].type === 'message_start' && frames.at(-1).type === 'message_stop', `${api}: valid stream envelope`)
|
|
check(!frames.some(x => x.type === 'error'), `${api}: provider metadata does not interrupt the stream`)
|
|
check(frames.filter(x => x.type === 'content_block_start').length === 2 && frames.filter(x => x.type === 'content_block_stop').length === 2, `${api}: closed text/tool blocks`)
|
|
check(frames.find(x => x.type === 'message_delta').usage.input_tokens === 9, `${api}: reported stream usage`)
|
|
check(frames.find(x => x.type === 'message_delta').delta.stop_reason === 'tool_use', `${api}: tool stop reason`)
|
|
} else {
|
|
const data = await result.json(); check(data.content[1].input.v === 2 && data.usage.input_tokens === 9, `${api}: nonstream content`)
|
|
}
|
|
}
|
|
const before = hits
|
|
check((await send(request, '/v1/messages', 'wrong')).status === 401, 'Wrong token denied')
|
|
check((await send(request, '/v1/other')).status === 404, 'Unknown path denied')
|
|
check((await send(request, '/v1/messages?url=http://bad')).status === 404, 'Query injection denied')
|
|
check(hits === before, 'Invalid requests never reach upstream')
|
|
const count = await send({ ...request, context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] } }, '/v1/messages/count_tokens'), countBody = await count.json()
|
|
check(countBody.estimated === true && count.headers.get('x-ycode-token-count-source')?.includes('estimate'), 'Token count explicitly estimated')
|
|
mode = 'error'; const error = await send(request); check(error.status === 401 && !(await error.text()).includes('mock-upstream-secret'), 'Upstream errors redacted')
|
|
mode = 'redirect'; check((await send(request)).status === 502, 'Redirect not followed')
|
|
mode = 'eof'; const eof = await (await send({ ...request, stream: true })).text(); check(eof.includes('event: error') && !eof.includes('event: message_stop'), 'Truncated stream fails explicitly')
|
|
mode = 'cancel'; aborted = false
|
|
const controller = new AbortController(), pending = await send({ ...request, stream: true }, '/v1/messages', lease.apiKey, controller.signal)
|
|
controller.abort(); await pending.body?.cancel().catch(() => {})
|
|
for (let i = 0; i < 100 && !aborted; i++) await new Promise(r => setTimeout(r, 10))
|
|
check(aborted, `${api}: client disconnect aborts upstream`)
|
|
} finally { await Promise.all([lease.close(), lease.close()]) }
|
|
await assert.rejects(fetch(lease.baseUrl)); checks++
|
|
}
|
|
const dir = await mkdtemp(join(await realpath(tmpdir()), 'ycode-bridge-'))
|
|
try {
|
|
for (const reasoningMode of ['hidden', 'reasoning']) {
|
|
const file = join(dir, reasoningMode + '.json')
|
|
const first = await acquireModelBridge(model('openai-responses', upstreamUrl), { historyFile: file })
|
|
mode = reasoningMode
|
|
let assistant: any[]
|
|
try {
|
|
const result = await fetch(first.baseUrl + '/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': first.apiKey }, body: JSON.stringify({ ...request, stream: reasoningMode === 'reasoning' }) })
|
|
if (reasoningMode === 'reasoning') {
|
|
const blocks: any[] = []
|
|
for await (const frame of readSSE(result.body!)) {
|
|
const f = frame as any
|
|
if (f.type === 'content_block_start') blocks[f.index] = { ...f.content_block }
|
|
if (f.type === 'content_block_delta') {
|
|
const b = blocks[f.index]
|
|
if (f.delta.type === 'input_json_delta') b.args = (b.args ?? '') + f.delta.partial_json
|
|
else b[b.type] += f.delta[b.type]
|
|
}
|
|
check(f.type !== 'error', 'Responses multioutput reasoning stream succeeds')
|
|
}
|
|
assistant = blocks.map(b => { if (b.type === 'tool_use') { b.input = JSON.parse(b.args); delete b.args }; return b })
|
|
} else assistant = (await result.json()).content
|
|
} finally { await first.close() }
|
|
const disk = await readFile(file, 'utf8')
|
|
check(disk.includes('opaque-private-history') && !disk.includes('mock-upstream-secret'), 'History persists opaque state without API credentials')
|
|
if (process.platform !== 'win32') check(((await stat(file)).mode & 0o777) === 0o600, 'History private file permissions')
|
|
const second = await acquireModelBridge(model('openai-responses', upstreamUrl), { historyFile: file })
|
|
try {
|
|
mode = 'normal'
|
|
const result = await fetch(second.baseUrl + '/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': second.apiKey }, body: JSON.stringify({ max_tokens: 50, messages: [...request.messages, { role: 'assistant', content: assistant! }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_2', content: 'ok' }] }] }) })
|
|
check(result.status === 200 && last.body.input.some((x: any) => x.encrypted_content === 'opaque-private-history'), `${reasoningMode}: close/reacquire replays opaque reasoning`)
|
|
await result.body?.cancel()
|
|
mode = 'mismatch'
|
|
const mismatch = await fetch(second.baseUrl + '/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': second.apiKey }, body: JSON.stringify({ ...request, stream: true }) })
|
|
check((await mismatch.text()).includes('event: error'), 'Responses final content mismatch fails')
|
|
} finally { await second.close() }
|
|
}
|
|
const bad = join(dir, 'bad.json'); await writeFile(bad, '{broken')
|
|
await assert.rejects(acquireModelBridge(model('openai-responses', upstreamUrl), { historyFile: bad }), /history/); checks++
|
|
await writeFile(bad, JSON.stringify({ version: 1, entries: [['[]', [{ type: 'reasoning', summary: 'invalid' }]]] }))
|
|
await assert.rejects(createConversionContext(bad), /history/); checks++
|
|
const snapshotFile = join(dir, 'snapshot.json'), saved = await createConversionContext(snapshotFile)
|
|
convertResponse(responseData, 'm', true, saved)
|
|
const saving = saved.persist!(); saved.responsesHistory.clear(); await saving
|
|
check((await createConversionContext(snapshotFile)).responsesHistory.size === 1, 'Persistence snapshots before await')
|
|
await writeFile(join(dir, 'blocked'), 'not a directory')
|
|
const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null)
|
|
check(blocked === null, 'Invalid history path fails closed')
|
|
await assert.rejects(createConversionContext(join(dir, 'blocked', 'missing', 'history.json')), /history/); checks++
|
|
await assert.rejects(createConversionContext(dir), /history/); checks++
|
|
const missingFile = join(dir, 'new', 'nested', 'history.json')
|
|
const fresh = await createConversionContext(missingFile)
|
|
check(fresh.responsesHistory.size === 0, 'Genuinely missing parents allow a new history')
|
|
await fresh.persist!()
|
|
check((await createConversionContext(missingFile)).responsesHistory.size === 0, 'New nested history persists and reloads')
|
|
|
|
// Simulate Windows' leaf ENOENT below a regular-file parent on every platform.
|
|
const blockedFile = join(dir, 'blocked', 'history.json'), originalLstat = fs.lstat
|
|
mock.method(fs, 'lstat', async (...args: Parameters<typeof fs.lstat>) => {
|
|
if (String(args[0]) === blockedFile) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
|
return originalLstat(...args)
|
|
})
|
|
syncBuiltinESMExports()
|
|
try { await assert.rejects(createConversionContext(blockedFile), /history/); checks++ }
|
|
finally { mock.restoreAll(); syncBuiltinESMExports() }
|
|
|
|
// Missing during read is not a new conversation; permission errors are never absence.
|
|
for (const [method, code, target] of [['readFile', 'ENOENT', snapshotFile], ['lstat', 'EACCES', snapshotFile], ['lstat', 'EACCES', dir]] as const) {
|
|
const original = fs[method]
|
|
mock.method(fs, method, async (...args: any[]) => {
|
|
if (String(args[0]) === target) throw Object.assign(new Error('unavailable'), { code })
|
|
return (original as Function)(...args)
|
|
})
|
|
syncBuiltinESMExports()
|
|
try { await assert.rejects(createConversionContext(snapshotFile), /history/); checks++ }
|
|
finally { mock.restoreAll(); syncBuiltinESMExports() }
|
|
}
|
|
|
|
const linkedDir = join(dir, 'linked'), targetDir = join(dir, 'target')
|
|
await mkdir(targetDir)
|
|
await symlink(targetDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir')
|
|
const linked = await createConversionContext(join(linkedDir, 'history.json'))
|
|
await linked.persist!()
|
|
check((await createConversionContext(join(targetDir, 'history.json'))).responsesHistory.size === 0, 'Trusted parent directory links remain supported')
|
|
const changedFile = join(dir, 'changed', 'history.json'), changed = await createConversionContext(changedFile)
|
|
await writeFile(join(dir, 'changed'), 'not a directory')
|
|
await assert.rejects(changed.persist!(), /history/); checks++
|
|
const danglingDir = join(dir, 'dangling')
|
|
await symlink(join(dir, 'absent-target'), danglingDir, process.platform === 'win32' ? 'junction' : 'dir')
|
|
await assert.rejects(createConversionContext(join(danglingDir, 'history.json')), /history/); checks++
|
|
if (process.platform !== 'win32') {
|
|
const linkedFile = join(dir, 'linked.json')
|
|
await symlink(snapshotFile, linkedFile)
|
|
await assert.rejects(createConversionContext(linkedFile), /history/); checks++
|
|
}
|
|
|
|
const concurrentFile = join(dir, 'concurrent.json')
|
|
const [left, right] = await Promise.all([createConversionContext(concurrentFile), createConversionContext(concurrentFile)])
|
|
left.responsesHistory.set(`${'a'.repeat(64)}:${'b'.repeat(64)}`, [])
|
|
right.responsesHistory.set(`${'c'.repeat(64)}:${'d'.repeat(64)}`, [])
|
|
await Promise.all([left.persist!(), right.persist!()])
|
|
check((await createConversionContext(concurrentFile)).responsesHistory.size === 2, 'Concurrent atomic history saves preserve both snapshots')
|
|
check(!(await fs.readdir(dir)).some(name => name.endsWith('.tmp')), 'Atomic history saves leave no temporary files')
|
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
} finally { upstream.closeAllConnections(); await new Promise<void>(resolve => upstream.close(() => resolve())) }
|
|
console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`)
|