705 lines
20 KiB
TypeScript
705 lines
20 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { applyStreamingPartDeltaToMessage, normalizeOpencodeSessionMessages } from '@/lib/opencode-message';
|
|
|
|
describe('opencode message normalization', () => {
|
|
it('keeps reasoning deltas as thinking content when the full part has not arrived yet', () => {
|
|
const message = applyStreamingPartDeltaToMessage(null, {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_assistant',
|
|
partID: 'part_reasoning',
|
|
field: 'text',
|
|
delta: 'Reviewing the project flow.',
|
|
part: {
|
|
id: 'part_reasoning',
|
|
type: 'reasoning',
|
|
},
|
|
});
|
|
|
|
expect(message).toEqual(expect.objectContaining({
|
|
id: 'msg_assistant',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'thinking',
|
|
id: 'part_reasoning',
|
|
thinking: 'Reviewing the project flow.',
|
|
},
|
|
],
|
|
}));
|
|
});
|
|
|
|
it('normalizes provider reasoning, reasoning_content, and reasoning_text aliases', () => {
|
|
const messages = normalizeOpencodeSessionMessages([{
|
|
id: 'msg_reasoning_aliases',
|
|
role: 'assistant',
|
|
reasoning: 'first',
|
|
reasoning_content: 'second',
|
|
content: [
|
|
{ type: 'reasoning', reasoning_text: 'third' },
|
|
{ type: 'text', text: 'answer' },
|
|
],
|
|
}]);
|
|
|
|
expect(messages[0]?.content).toEqual([
|
|
{ type: 'thinking', thinking: 'first' },
|
|
{ type: 'thinking', thinking: 'third' },
|
|
{ type: 'text', text: 'answer' },
|
|
]);
|
|
});
|
|
|
|
it('surfaces a completed assistant message with no visible content as an error', () => {
|
|
const messages = normalizeOpencodeSessionMessages([
|
|
{
|
|
info: {
|
|
id: 'msg_empty_assistant',
|
|
role: 'assistant',
|
|
sessionID: 'ses_1',
|
|
time: { created: 1747040000000, completed: 1747040001000 },
|
|
},
|
|
parts: [
|
|
{ id: 'step_start', type: 'step-start' },
|
|
{ id: 'step_finish', type: 'step-finish' },
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(messages).toEqual([
|
|
expect.objectContaining({
|
|
id: 'msg_empty_assistant',
|
|
role: 'assistant',
|
|
isError: true,
|
|
errorMessage: '模型没有返回任何内容。请重试,或切换到支持图片输入的模型。',
|
|
content: [{
|
|
type: 'text',
|
|
text: '模型没有返回任何内容。请重试,或切换到支持图片输入的模型。',
|
|
}],
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('retains bounded runtime tool evidence without exposing raw tool state', () => {
|
|
const messages = normalizeOpencodeSessionMessages([
|
|
{
|
|
info: {
|
|
id: 'msg_tool_evidence',
|
|
role: 'assistant',
|
|
time: { created: 1747040000000, completed: 1747040001000 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'tool_build',
|
|
callID: 'call_build',
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
state: {
|
|
status: 'completed',
|
|
input: { command: 'pnpm build' },
|
|
output: 'build completed',
|
|
metadata: { exit: 0 },
|
|
time: { start: 1747040000000, end: 1747040000500 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(messages[0]?._toolEvidence).toEqual([
|
|
expect.objectContaining({
|
|
id: 'tool_build',
|
|
toolCallId: 'call_build',
|
|
name: 'bash',
|
|
status: 'completed',
|
|
inputSummary: expect.stringContaining('pnpm build'),
|
|
exitCode: 0,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('preserves bounded user runtime context and only the true synthetic bit', () => {
|
|
const [message] = normalizeOpencodeSessionMessages([{
|
|
info: {
|
|
id: 'msg_user',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_000 },
|
|
agent: 'game-development',
|
|
model: {
|
|
providerID: 'niancode-user-models',
|
|
modelID: 'qwen3.7-plus',
|
|
variant: 'high',
|
|
},
|
|
system: 'SECRET SYSTEM PROMPT',
|
|
},
|
|
parts: [
|
|
{ id: 'part_visible', type: 'text', text: 'Visible request', synthetic: false },
|
|
{ id: 'part_hidden', type: 'text', text: 'Injected context', synthetic: true },
|
|
],
|
|
}]);
|
|
|
|
expect(message?._runtimeContext).toEqual({
|
|
agent: 'game-development',
|
|
model: 'niancode-user-models/qwen3.7-plus',
|
|
variant: 'high',
|
|
});
|
|
expect(message?.content).toEqual([
|
|
{ id: 'part_visible', type: 'text', text: 'Visible request' },
|
|
{ id: 'part_hidden', type: 'text', text: 'Injected context', synthetic: true },
|
|
]);
|
|
expect(JSON.stringify(message)).not.toContain('SECRET SYSTEM PROMPT');
|
|
});
|
|
|
|
it('drops oversized runtime identifiers instead of retaining arbitrary metadata', () => {
|
|
const [message] = normalizeOpencodeSessionMessages([{
|
|
info: {
|
|
id: 'msg_user',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_000 },
|
|
agent: 'a'.repeat(200),
|
|
model: {
|
|
providerID: 'provider',
|
|
modelID: 'model',
|
|
variant: 'v'.repeat(200),
|
|
},
|
|
},
|
|
parts: [{ type: 'text', text: 'Hello' }],
|
|
}]);
|
|
|
|
expect(message?._runtimeContext).toEqual({ model: 'provider/model' });
|
|
});
|
|
|
|
it('renders only a persisted command invocation and its whitelisted context', () => {
|
|
const [message] = normalizeOpencodeSessionMessages([{
|
|
info: {
|
|
id: 'msg_command',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_000 },
|
|
agent: 'game-development',
|
|
model: {
|
|
providerID: 'niancode-user-models',
|
|
modelID: 'qwen3.7-plus',
|
|
},
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_invocation',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: ' raw arguments ',
|
|
contextPartIDs: ['prt_context', 'prt_image'],
|
|
},
|
|
{
|
|
id: 'prt_template',
|
|
type: 'text',
|
|
text: 'PRIVATE EXPANDED TEMPLATE',
|
|
synthetic: true,
|
|
},
|
|
{
|
|
id: 'prt_context',
|
|
type: 'text',
|
|
text: 'Bounded selected path context',
|
|
synthetic: true,
|
|
},
|
|
{
|
|
id: 'prt_image',
|
|
type: 'file',
|
|
mime: 'image/webp',
|
|
filename: 'screen.webp',
|
|
url: 'data:image/webp;base64,QUFB',
|
|
},
|
|
{
|
|
id: 'prt_plugin',
|
|
type: 'text',
|
|
text: 'PRIVATE PLUGIN TEXT',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
}]);
|
|
|
|
expect(message?.content?.[0]).toEqual({
|
|
id: 'prt_invocation',
|
|
type: 'text',
|
|
text: '/Review raw arguments ',
|
|
});
|
|
expect(JSON.stringify(message)).toContain('Bounded selected path context');
|
|
expect(message?._attachedFiles ?? []).toHaveLength(1);
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE EXPANDED TEMPLATE');
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE PLUGIN TEXT');
|
|
expect(JSON.stringify(message)).not.toContain('contextPartIDs');
|
|
});
|
|
|
|
it('fails closed when a command invocation marker is malformed or duplicated', () => {
|
|
const messages = normalizeOpencodeSessionMessages([
|
|
{
|
|
info: {
|
|
id: 'msg_malformed',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_000 },
|
|
agent: 'game-development',
|
|
model: { providerID: 'provider', modelID: 'model' },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_bad',
|
|
type: 'command-invocation',
|
|
command: '',
|
|
arguments: '',
|
|
contextPartIDs: ['not-a-part-id'],
|
|
},
|
|
{ id: 'prt_template', type: 'text', text: 'PRIVATE MALFORMED TEMPLATE', synthetic: true },
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: 'msg_duplicated',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_001 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_first',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_context'],
|
|
},
|
|
{
|
|
id: 'prt_second',
|
|
type: 'command-invocation',
|
|
command: 'Explain',
|
|
arguments: '',
|
|
contextPartIDs: [],
|
|
},
|
|
{
|
|
id: 'prt_context',
|
|
type: 'text',
|
|
text: 'PRIVATE DUPLICATED TEMPLATE',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: 'msg_control',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_002 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_control',
|
|
type: 'command-invocation',
|
|
command: 'Review\u0000hidden',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_control_context'],
|
|
},
|
|
{
|
|
id: 'prt_control_context',
|
|
type: 'text',
|
|
text: 'PRIVATE CONTROL TEMPLATE',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: 'msg_invalid_marker_id',
|
|
role: 'user',
|
|
time: { created: 1_700_000_000_003 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'arbitrary-renderer-id',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_invalid_id_context'],
|
|
},
|
|
{
|
|
id: 'prt_invalid_id_context',
|
|
type: 'text',
|
|
text: 'PRIVATE INVALID ID TEMPLATE',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(messages).toHaveLength(4);
|
|
for (const message of messages) {
|
|
expect(message.content).toEqual([{
|
|
type: 'text',
|
|
text: '[Project command metadata unavailable]',
|
|
}]);
|
|
expect(message._attachedFiles ?? []).toEqual([]);
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE');
|
|
}
|
|
});
|
|
|
|
it('fails closed for command markers on every non-user role', () => {
|
|
const messages = normalizeOpencodeSessionMessages(
|
|
['assistant', 'system', 'unknown-runtime-role'].map((role, index) => ({
|
|
info: {
|
|
id: `msg_non_user_${index}`,
|
|
role,
|
|
time: { created: 1_700_000_001_000 + index },
|
|
},
|
|
parts: [
|
|
{
|
|
id: `prt_marker_${index}`,
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '--private',
|
|
contextPartIDs: [`prt_private_${index}`, `prt_file_${index}`, `prt_tool_${index}`],
|
|
},
|
|
{
|
|
id: `prt_private_${index}`,
|
|
type: 'text',
|
|
text: `PRIVATE_ROLE_${index}`,
|
|
synthetic: true,
|
|
},
|
|
{
|
|
id: `prt_file_${index}`,
|
|
type: 'file',
|
|
mime: 'image/png',
|
|
filename: 'private.png',
|
|
url: 'data:image/png;base64,QUFB',
|
|
},
|
|
{
|
|
id: `prt_tool_${index}`,
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
state: { input: { command: 'print-private-data' } },
|
|
},
|
|
],
|
|
})),
|
|
);
|
|
|
|
expect(messages).toHaveLength(3);
|
|
for (const message of messages) {
|
|
expect(message.content).toEqual([{
|
|
type: 'text',
|
|
text: '[Project command metadata unavailable]',
|
|
}]);
|
|
expect(message._attachedFiles ?? []).toEqual([]);
|
|
expect(message._toolEvidence ?? []).toEqual([]);
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE_ROLE');
|
|
expect(JSON.stringify(message)).not.toContain('print-private-data');
|
|
expect(JSON.stringify(message)).not.toContain('--private');
|
|
}
|
|
});
|
|
|
|
it('never renders tool or reasoning parts referenced by a valid user command marker', () => {
|
|
const [message] = normalizeOpencodeSessionMessages([{
|
|
info: {
|
|
id: 'msg_user_unsafe_context',
|
|
role: 'user',
|
|
time: { created: 1_700_000_001_500 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_user_unsafe_marker',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: 'safe arguments',
|
|
contextPartIDs: [
|
|
'prt_user_safe_text',
|
|
'prt_user_private_reasoning',
|
|
'prt_user_private_tool',
|
|
],
|
|
},
|
|
{
|
|
id: 'prt_user_safe_text',
|
|
type: 'text',
|
|
text: 'Visible selected context',
|
|
synthetic: true,
|
|
},
|
|
{
|
|
id: 'prt_user_private_reasoning',
|
|
type: 'reasoning',
|
|
text: 'PRIVATE_REASONING',
|
|
},
|
|
{
|
|
id: 'prt_user_private_tool',
|
|
callID: 'call_user_private_tool',
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
state: {
|
|
status: 'completed',
|
|
input: { command: 'PRIVATE_TOOL_INPUT' },
|
|
},
|
|
},
|
|
],
|
|
}]);
|
|
|
|
expect(message?.content).toEqual([
|
|
{
|
|
id: 'prt_user_unsafe_marker',
|
|
type: 'text',
|
|
text: '/Review safe arguments',
|
|
},
|
|
{
|
|
id: 'prt_user_safe_text',
|
|
type: 'text',
|
|
text: 'Visible selected context',
|
|
synthetic: true,
|
|
},
|
|
]);
|
|
expect(message?._toolEvidence ?? []).toEqual([]);
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE_REASONING');
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE_TOOL_INPUT');
|
|
});
|
|
|
|
it('fails closed for malformed structured-like and legacy command marker shapes', () => {
|
|
const messages = normalizeOpencodeSessionMessages([
|
|
{
|
|
id: 'msg_missing_info',
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
id: 'prt_missing_info',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_missing_info_private'],
|
|
},
|
|
{
|
|
id: 'prt_missing_info_private',
|
|
type: 'text',
|
|
text: 'PRIVATE_MISSING_INFO',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: 'msg_non_array_parts',
|
|
role: 'user',
|
|
time: { created: 1_700_000_002_000 },
|
|
},
|
|
parts: {
|
|
marker: {
|
|
id: 'prt_non_array',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_non_array_private'],
|
|
},
|
|
privatePart: {
|
|
id: 'prt_non_array_private',
|
|
type: 'text',
|
|
text: 'PRIVATE_NON_ARRAY_PARTS',
|
|
synthetic: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: 'msg_legacy_array',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
id: 'prt_legacy_marker',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_legacy_private'],
|
|
},
|
|
{
|
|
id: 'prt_legacy_private',
|
|
type: 'text',
|
|
text: 'PRIVATE_LEGACY_ARRAY',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'msg_array_info',
|
|
info: [],
|
|
parts: [
|
|
{
|
|
id: 'prt_array_info_marker',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_array_info_private'],
|
|
},
|
|
{
|
|
id: 'prt_array_info_private',
|
|
type: 'text',
|
|
text: 'PRIVATE_ARRAY_INFO',
|
|
synthetic: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'msg_ordinary_legacy',
|
|
role: 'assistant',
|
|
content: 'Ordinary legacy content',
|
|
},
|
|
]);
|
|
|
|
expect(messages).toHaveLength(5);
|
|
for (const message of messages.slice(0, 4)) {
|
|
expect(message.content).toEqual([{
|
|
type: 'text',
|
|
text: '[Project command metadata unavailable]',
|
|
}]);
|
|
expect(message._attachedFiles ?? []).toEqual([]);
|
|
expect(message._toolEvidence ?? []).toEqual([]);
|
|
expect(JSON.stringify(message)).not.toContain('PRIVATE');
|
|
expect(JSON.stringify(message)).not.toContain('command-invocation');
|
|
}
|
|
expect(messages[4]?.content).toEqual([{
|
|
type: 'text',
|
|
text: 'Ordinary legacy content',
|
|
}]);
|
|
});
|
|
|
|
it('preserves legacy normalization for structured-like records without a command marker', () => {
|
|
const messages = normalizeOpencodeSessionMessages([
|
|
{
|
|
id: 'msg_legacy_info',
|
|
role: 'assistant',
|
|
text: 'Legacy info content',
|
|
info: { source: 'legacy-adapter' },
|
|
},
|
|
{
|
|
id: 'msg_legacy_parts',
|
|
role: 'assistant',
|
|
text: 'Legacy parts content',
|
|
parts: { source: 'legacy-adapter' },
|
|
},
|
|
]);
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
id: 'msg_legacy_info',
|
|
role: 'assistant',
|
|
timestamp: undefined,
|
|
content: [{ type: 'text', text: 'Legacy info content' }],
|
|
},
|
|
{
|
|
id: 'msg_legacy_parts',
|
|
role: 'assistant',
|
|
timestamp: undefined,
|
|
content: [{ type: 'text', text: 'Legacy parts content' }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('requires string arguments and enforces command selector bounds', () => {
|
|
const invalidArguments: unknown[] = [undefined, null, 42, { private: true }];
|
|
const messages = normalizeOpencodeSessionMessages([
|
|
...invalidArguments.map((argumentsValue, index) => ({
|
|
info: {
|
|
id: `msg_bad_arguments_${index}`,
|
|
role: 'user',
|
|
time: { created: 1_700_000_003_000 + index },
|
|
},
|
|
parts: [{
|
|
id: `prt_bad_arguments_${index}`,
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
...(argumentsValue === undefined ? {} : { arguments: argumentsValue }),
|
|
contextPartIDs: [],
|
|
}],
|
|
})),
|
|
{
|
|
info: { id: 'msg_whitespace_command', role: 'user' },
|
|
parts: [{
|
|
id: 'prt_whitespace_command',
|
|
type: 'command-invocation',
|
|
command: ' Review',
|
|
arguments: '',
|
|
contextPartIDs: [],
|
|
}],
|
|
},
|
|
{
|
|
info: { id: 'msg_leading_slash_command', role: 'user' },
|
|
parts: [{
|
|
id: 'prt_leading_slash_command',
|
|
type: 'command-invocation',
|
|
command: '/Review',
|
|
arguments: '',
|
|
contextPartIDs: [],
|
|
}],
|
|
},
|
|
{
|
|
info: { id: 'msg_oversized_arguments', role: 'user' },
|
|
parts: [{
|
|
id: 'prt_oversized_arguments',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: 'a'.repeat(8_193),
|
|
contextPartIDs: [],
|
|
}],
|
|
},
|
|
{
|
|
info: { id: 'msg_too_many_context_ids', role: 'user' },
|
|
parts: [{
|
|
id: 'prt_too_many_context_ids',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: Array.from({ length: 65 }, (_, index) => `prt_context_${index}`),
|
|
}],
|
|
},
|
|
]);
|
|
|
|
expect(messages).toHaveLength(8);
|
|
for (const message of messages) {
|
|
expect(message.content).toEqual([{
|
|
type: 'text',
|
|
text: '[Project command metadata unavailable]',
|
|
}]);
|
|
expect(message._attachedFiles ?? []).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('does not mistake a large ordinary tool payload for command metadata', () => {
|
|
const [message] = normalizeOpencodeSessionMessages([{
|
|
info: {
|
|
id: 'msg_large_tool_payload',
|
|
role: 'assistant',
|
|
time: { created: 1_700_000_005_000 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_visible_result',
|
|
type: 'text',
|
|
text: 'Visible assistant result',
|
|
},
|
|
{
|
|
id: 'prt_large_tool',
|
|
callID: 'call_large_tool',
|
|
type: 'tool',
|
|
tool: 'inspect',
|
|
state: {
|
|
status: 'completed',
|
|
input: Array.from({ length: 4_100 }, (_, index) => ({ index })),
|
|
output: 'Inspection complete',
|
|
},
|
|
},
|
|
],
|
|
}]);
|
|
|
|
expect(message?.content).toEqual([
|
|
{
|
|
id: 'prt_visible_result',
|
|
type: 'text',
|
|
text: 'Visible assistant result',
|
|
},
|
|
expect.objectContaining({
|
|
id: 'call_large_tool',
|
|
type: 'tool_use',
|
|
name: 'inspect',
|
|
}),
|
|
]);
|
|
expect(message?._toolEvidence).toEqual([
|
|
expect.objectContaining({
|
|
id: 'prt_large_tool',
|
|
toolCallId: 'call_large_tool',
|
|
name: 'inspect',
|
|
status: 'completed',
|
|
}),
|
|
]);
|
|
});
|
|
});
|