getMethod($method)->invokeArgs(null, $args);
}
function filterFrame(array $data): string
{
return 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\r\n\r\n";
}
function filterDecode(string $protocol, array $chunks): array
{
return filterPrivate('decodeStreamChunks', [$protocol, $chunks]);
}
$cases = [
['PRIVATE_PLAN临床正文。', '临床正文。'],
['PRIVATE_PLAN正文', '正文'],
['outerinnerouter正文', '正文'],
['前文PRIVATE_PLAN后文', '前文后文'],
['onetwo正文', '正文'],
[" \nPRIVATE_PLAN\n正文", " \n\n正文"],
[" \nPRIVATE_PLAN\n\t", ''],
['PRIVATE_PLAN', ''],
['PRIVATE_PLANPRIVATE_PLAN正文', '正文'],
['普通 thinking / planning;x < y;文字;<think>',
'普通 thinking / planning;x < y;文字;<think>'],
['正文末尾 <', '正文末尾 <'],
['正文末尾 内容', '正文 内容'],
];
foreach ($cases as [$raw, $expected]) {
filterCheck(DifyReasoningTextFilter::text($raw) === $expected, 'Whole text preserves exactly the visible body.');
for ($cut = 0; $cut <= strlen($raw); ++$cut) {
$filter = new DifyReasoningTextFilter();
$actual = $filter->push(substr($raw, 0, $cut)) . $filter->push(substr($raw, $cut)) . $filter->finish();
filterCheck($actual === $expected, 'Any byte split matches whole-text filtering.');
filterCheck($filter->finish() === '' && $filter->push('late') === '', 'Terminal filtering is idempotent.');
foreach ($filter->counts() as $count) filterCheck(is_int($count), 'Filter diagnostics contain only integers.');
}
$filter = new DifyReasoningTextFilter(); $visible = '';
foreach (str_split($raw) as $byte) $visible .= $filter->push($byte);
$visible .= $filter->finish();
filterCheck($visible === $expected, 'One-byte filter chunks preserve UTF-8 bytes and tag boundaries.');
}
foreach (['dify', 'openai'] as $protocol) {
$wire = '';
foreach (['<', 'th', 'ink>', 'PRIVATE_PLAN', '', 'thi', 'nk>', '临床', '正文。'] as $part) {
$wire .= filterFrame($protocol === 'dify' ? ['event' => 'message', 'answer' => $part]
: ['choices' => [['delta' => ['content' => $part]]]]);
}
$wire .= $protocol === 'dify' ? filterFrame(['event' => 'message_end']) : "data: [DONE]\r\n\r\n";
for ($cut = 0; $cut <= strlen($wire); ++$cut) {
$out = filterDecode($protocol, [substr($wire, 0, $cut), substr($wire, $cut)]);
filterCheck($out['content'] === '临床正文。' && implode('', $out['deltas']) === $out['content'] && $out['finished'],
'SSE wire splits and token-level tag splits never leak thinking.');
}
$out = filterDecode($protocol, str_split($wire));
filterCheck($out['content'] === '临床正文。', 'One-byte SSE network reads retain exact clinical body.');
$blocking = $protocol === 'dify' ? ['answer' => 'PRIVATE_PLAN临床正文。', 'thought' => 'PRIVATE_FIELD']
: ['choices' => [['message' => ['reasoning_content' => 'PRIVATE_FIELD', 'content' => 'PRIVATE_PLAN临床正文。']]]];
$formatted = filterPrivate('formatResponse', [['errno' => 0, 'http_code' => 200,
'body' => json_encode($blocking, JSON_UNESCAPED_UNICODE)], microtime(true)]);
filterCheck($formatted['ok'] && $formatted['content'] === $out['content'], 'Blocking and streaming expose the same body.');
foreach (['', 'PRIVATE_PLAN', 'PRIVATE_PLAN 'message', 'answer' => $reasoningOnly, 'thought' => 'PRIVATE_FIELD']
: ['choices' => [['delta' => ['content' => $reasoningOnly, 'reasoning_content' => 'PRIVATE_FIELD']]]]);
$wire .= $protocol === 'dify' ? filterFrame(['event' => 'message_end']) : "data: [DONE]\n\n";
$out = filterDecode($protocol, str_split($wire));
filterCheck($out['content'] === '' && $out['deltas'] === [], 'Reasoning-only and unclosed blocks emit no clinical delta.');
$result = filterPrivate('formatStreamResponse', [['errno' => 0, 'http_code' => 200, 'content' => $out['content'],
'finished' => $out['finished']], microtime(true)]);
filterCheck(!$result['ok'] && $result['error_code'] === 'EMPTY_RESPONSE', 'Reasoning-only stream is never report success.');
$payload = $protocol === 'dify' ? ['answer' => $reasoningOnly, 'thought' => 'PRIVATE_FIELD']
: ['choices' => [['message' => ['content' => $reasoningOnly, 'reasoning_content' => 'PRIVATE_FIELD']]]];
$result = filterPrivate('formatResponse', [['errno' => 0, 'http_code' => 200,
'body' => json_encode($payload)], microtime(true)]);
filterCheck(!$result['ok'] && $result['error_code'] === 'EMPTY_RESPONSE', 'Reasoning-only blocking reply also fails empty.');
}
}
$parts = ['choices' => [['delta' => ['reasoning_content' => 'PRIVATE_FIELD', 'content' => [
['type' => 'reasoning', 'text' => 'PRIVATE_PART'], ['type' => 'text', 'text' => 'PRIVATE_PLAN'],
['type' => 'text', 'text' => '真正正文'],
]]]]];
$out = filterDecode('openai', [filterFrame($parts), "data: [DONE]\n\n"]);
filterCheck($out['content'] === '真正正文', 'Dedicated reasoning fields/parts stay private alongside genuine text parts.');
$parts['choices'][0]['message'] = $parts['choices'][0]['delta']; unset($parts['choices'][0]['delta']);
filterCheck(filterPrivate('extractContent', [$parts]) === '真正正文', 'Blocking text-part arrays use the same filter.');
// Assert the real upstream clock never sees an inline plan as first answer.
$now = 0.0;
$timing = new DifyStreamTiming(static function () use (&$now): float { return $now; });
$state = filterPrivate('newStreamState', []); $state['timing'] = $timing;
$tasks = []; $terminals = []; $received = [];
$state['on_task'] = static function (string $id) use (&$tasks): void { $tasks[] = $id; };
$state['on_terminal'] = static function (string $id, string $status) use (&$terminals): void { $terminals[] = [$id, $status]; };
$consume = (new ReflectionClass(DifyChatService::class))->getMethod('consumeStreamFrame');
$receive = static function (string $delta) use (&$received): bool { $received[] = $delta; return true; };
foreach ([[10, ['event' => 'workflow_started', 'task_id' => 'filter-task']],
[20, ['event' => 'message', 'task_id' => 'filter-task', 'answer' => 'PRIVATE_PLAN']],
[30, ['event' => 'message', 'task_id' => 'filter-task', 'answer' => "\n"]]] as [$now, $event]) {
$consume->invokeArgs(null, ['dify', filterFrame($event), &$state, $receive]);
filterCheck(!isset($timing->snapshot()['first_answer_ms']) && $received === [], 'No first answer/delivery from thinking or whitespace.');
}
$now = 40;
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message', 'answer' => '正文']), &$state, $receive]);
$now = 50;
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message_end']), &$state, $receive]);
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'workflow_finished', 'task_id' => 'filter-task', 'data' => ['status' => 'succeeded']]), &$state, $receive]);
filterCheck($timing->snapshot()['first_answer_ms'] === 40 && $timing->snapshot()['first_delivered_ms'] === 40,
'True clinical text establishes first answer and delivery.');
filterCheck($tasks === ['filter-task'] && $terminals === [['filter-task', 'succeeded']], 'Task and terminal observers survive filtering.');
filterCheck($state['finished'] && $state['content'] === "\n正文" && implode('', $received) === $state['content'], 'Exact visible bytes and terminal completion stay consistent.');
filterCheck(!str_contains(json_encode($timing->snapshot()), 'PRIVATE_'), 'Telemetry never retains reasoning contents.');
$state = filterPrivate('newStreamState', []); $state['discard_deltas'] = true; $received = [];
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message', 'answer' => 'PRIVATE_PLANlate']), &$state, $receive]);
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message_end']), &$state, $receive]);
filterCheck($received === [] && $state['content'] === '', 'Cancelled/timeout discard state never releases filtered or buffered text.');
echo "Dify incremental reasoning filter: {$assertions} assertions passed (pure offline fixtures).\n";