$base['questions'], 'summary' => $base['summary'], 'checks' => $base['checks'], 'review' => []]; $ordered['questions'][] = ['text' => '本次最希望了解哪方面?', 'evidence_ids' => ['seg-1']]; $ordered['questions'][] = ['text' => '这些情况有变化吗?', 'evidence_ids' => ['seg-1']]; $full = Context::encode($ordered); $first = '{"questions":' . Context::encode($ordered['questions']); $updates = []; $upstreamFinished = false; $events = new AssistantSseProtocol(); liveExpect($events->encode('live_update', []) === null, 'live update cannot precede authorization/start'); $events->encode('start', ['task' => 'live_consultation']); $onUpdate = static function (array $payload) use (&$updates, &$upstreamFinished, $events, $prepared): bool { liveExpect(!$upstreamFinished && $payload['live_partial'] === true, 'validated draft arrives before upstream final response'); liveExpect($payload['diagnosis_id'] === 7 && $payload['analysis_scope'] === 'current_call_only' && $payload['live_context']['revision'] === $prepared['live_context']['revision'], 'every update binds original authorized diagnosis and complete source revision'); $frame = $events->encode('live_update', $payload); liveExpect(is_string($frame) && str_starts_with($frame, "event: live_update\n"), 'protocol emits validated draft event'); preg_match('/^data: (.+)$/m', $frame, $matches); $decoded = json_decode($matches[1], true, 512, JSON_THROW_ON_ERROR); liveExpect($decoded['analysis_scope'] === 'current_call_only' && $decoded['live_assistance'] === $payload['live_assistance'] && $decoded['live_context'] === $payload['live_context'], 'SSE serialization preserves cumulative citations/groups'); $updates[] = $payload; return true; }; DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($ordered, $first, $full, &$updates, &$upstreamFinished): array { liveExpect($delta('{"questions":[' . Context::encode($ordered['questions'][0])) && $updates === [], 'complete first object waits for its legal array delimiter'); liveExpect($delta(',') && count($updates) === 1 && count($updates[0]['live_assistance']['questions']) === 1 && $updates[0]['live_completed_groups'] === 0, 'first question arrives while its array and later questions are unfinished'); liveExpect($delta(Context::encode($ordered['questions'][1]) . ',') && count($updates) === 2, 'second question extends the same cumulative group'); liveExpect($delta(Context::encode($ordered['questions'][2]) . ']') && count($updates) === 3, 'third question closes the first group'); foreach (str_split(substr($full, strlen($first)), 1) as $byte) liveExpect($delta($byte), 'split UTF-8 bytes preserve stream'); liveExpect(count($updates) === 5, 'only added visible items emit; empty review and final braces do not repeat frames'); $upstreamFinished = true; return ['ok' => true, 'content' => $full]; }; $before = DifyChatService::$calls; $final = Logic::streamPreparedAssistant($prepared, $sink, null, null, ['on_live_update' => $onUpdate]); liveExpect(DifyChatService::$handlerFailure === null, 'progress handler assertions succeed: ' . DifyChatService::$handlerFailure); liveExpect(is_array($final) && DifyChatService::$calls === $before + 1 && $visible === [], 'progress stays one generation with no raw delta'); liveExpect($final['live_partial'] === false && end($updates)['live_assistance'] === $final['live_assistance'], 'authoritative done uses the same final validated groups'); foreach ($updates as $index => $update) { liveExpect($update['live_completed_groups'] === [0, 0, 1, 2, 3][$index], 'complete groups retain their original meaning'); if ($index > 0) foreach ($updates[$index - 1]['live_assistance'] as $group => $items) liveExpect(array_slice($update['live_assistance'][$group], 0, count($items)) === $items, 'previous validated items remain unchanged'); } $events->encode('done', $final); liveExpect($events->encode('live_update', $updates[0]) === null, 'no update can arrive after final'); // A later invalid section leaves the earlier update provisional and ends in a categorized error. foreach (['citation', 'format', 'safety', 'oversized', 'timeout', 'cancel', 'disconnect', 'upstream'] as $mode) { $sent = []; $cancel = false; $bad = $ordered; if ($mode === 'citation') $bad['summary'][0]['evidence_ids'] = ['wrong-call']; if ($mode === 'safety') $bad['checks'][0]['text'] = '核实后建议立即服用某药'; $badFull = $mode === 'format' ? Context::encode($bad) . 'extra' : Context::encode($bad); $handlerUpdate = static function (array $payload) use (&$sent, $mode): bool { if ($mode === 'disconnect') return false; $sent[] = $payload; return true; }; DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta, $abort) use ($first, $badFull, $mode, &$cancel, &$sent): array { $accepted = $delta($first); if ($mode === 'disconnect') { liveExpect(!$accepted && $abort() && $sent === [], 'rejected update stops producer'); } else { liveExpect($accepted && count($sent) === 1, 'one complete group visible before later failure'); if ($mode === 'timeout') $GLOBALS['liveClockAdvanceNs'] += 6000000000; elseif ($mode === 'cancel') $cancel = true; elseif ($mode === 'oversized') liveExpect(!$delta(str_repeat('x', Result::MAX_BYTES)) && $abort(), 'oversize abort after first update'); elseif ($mode !== 'upstream') $delta(substr($badFull, strlen($first))); } if ($mode === 'upstream') return ['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE', 'error' => 'unsafe upstream https://secret.invalid/token-secret patient text']; return ['ok' => true, 'content' => $badFull]; }; $before = DifyChatService::$calls; $result = Logic::streamPreparedAssistant($prepared, $sink, static function () use (&$cancel): bool { return $cancel; }, null, ['on_live_update' => $handlerUpdate]); liveExpect(DifyChatService::$handlerFailure === null, 'late failure handler assertions: ' . DifyChatService::$handlerFailure); $meta = Logic::getAssistantErrorMeta(); $category = ['oversized' => 'format', 'cancel' => 'cancelled', 'disconnect' => 'cancelled'][$mode] ?? $mode; liveExpect($result === null && DifyChatService::$calls === $before + 1 && $visible === [], 'late failure never retries or emits raw model JSON'); liveExpect(($meta['live_error_category'] ?? null) === $category && ($meta['live_partial_available'] ?? null) === ($mode !== 'disconnect'), 'safe category and provisional availability: ' . $mode); liveExpect(!str_contains(Context::encode($meta) . Logic::getError(), 'token-secret'), 'raw upstream error never leaks'); $GLOBALS['liveClockAdvanceNs'] = 0; } // Invalid first group cannot emit even if other groups eventually close. $bad = $ordered; $bad['questions'][0]['evidence_ids'] = ['foreign']; $badJson = Context::encode($bad); $sent = []; DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($badJson): array { foreach (str_split($badJson, 7) as $chunk) $delta($chunk); return ['ok' => true, 'content' => $badJson]; }; liveExpect(Logic::streamPreparedAssistant($prepared, $sink, null, null, ['on_live_update' => static function (array $payload) use (&$sent): void { $sent[] = $payload; }]) === null && $sent === [] && Logic::getAssistantErrorMeta()['live_validation_reason'] === 'CITATION_INVALID', 'invalid first citation never emits a provisional payload'); // Exercise the real callback at each byte with all legal display slots filled. foreach (['live_consultation' => 12, 'live_review' => 15] as $task => $limit) { $boundedPrepared = $prepared; $boundedPrepared['task'] = $task; $bounded = ['questions' => [], 'summary' => [], 'checks' => [], 'review' => []]; for ($index = 0; $index < 6; $index++) { $id = 'seg-' . (1 + $index * 4); $bounded['questions'][] = ['text' => '第' . $index . '项希望补充什么?', 'evidence_ids' => [$id]]; $bounded['summary'][] = ['text' => '由真实原文还原', 'evidence_ids' => [$id]]; $bounded['checks'][] = ['text' => '确认第' . $index . '项的信息', 'evidence_ids' => [$id]]; if ($task === 'live_review') $bounded['review'][] = ['text' => '第' . $index . '项可进一步核实', 'evidence_ids' => [$id]]; } $boundedJson = Context::encode($bounded); $boundedUpdates = []; DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($boundedJson): array { foreach (str_split($boundedJson) as $byte) { if (!$delta($byte)) throw new RuntimeException('valid bounded stream unexpectedly stopped'); } return ['ok' => true, 'content' => $boundedJson]; }; $boundedResult = Logic::streamPreparedAssistant($boundedPrepared, $sink, null, null, ['on_live_update' => static function (array $payload) use (&$boundedUpdates): void { $boundedUpdates[] = $payload; }]); liveExpect(DifyChatService::$handlerFailure === null && is_array($boundedResult) && count($boundedUpdates) === $limit, 'frame count is bounded by actual displayed items for ' . $task); foreach ($boundedUpdates as $index => $payload) { liveExpect(array_sum(array_map('count', $payload['live_assistance'])) === $index + 1, 'every emitted frame adds exactly one validated item under byte-wise input'); if ($index > 0) foreach ($boundedUpdates[$index - 1]['live_assistance'] as $group => $items) liveExpect(array_slice($payload['live_assistance'][$group], 0, count($items)) === $items, 'all earlier items stay byte-for-byte unchanged'); } liveExpect(end($boundedUpdates)['live_assistance'] === $boundedResult['live_assistance'], 'maximum progressive result equals authoritative final validation'); } // A bad second item in the same still-open group leaves only the first validated item. foreach (['citation', 'safety', 'syntax', 'hidden_safety'] as $mode) { $sameGroup = $ordered; if ($mode === 'citation') $sameGroup['questions'][1]['evidence_ids'] = ['foreign']; if ($mode === 'safety') $sameGroup['questions'][1]['text'] = '建议立即服用某药吗?'; if ($mode === 'hidden_safety') $sameGroup['questions'][] = ['text' => '建议立即服用某药吗?', 'evidence_ids' => ['seg-1']]; $sameJson = $mode === 'syntax' ? '{"questions":[' . Context::encode($sameGroup['questions'][0]) . ',]}' : Context::encode($sameGroup); $sameUpdates = []; DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($sameJson): array { foreach (str_split($sameJson) as $byte) $delta($byte); return ['ok' => true, 'content' => $sameJson]; }; $failed = Logic::streamPreparedAssistant($prepared, $sink, null, null, ['on_live_update' => static function (array $payload) use (&$sameUpdates): void { $sameUpdates[] = $payload; }]); liveExpect($failed === null && count($sameUpdates) === ($mode === 'hidden_safety' ? 3 : 1) && Logic::getAssistantErrorMeta()['live_partial_available'] === true, 'invalid same-group or hidden item cannot bypass final gate: ' . $mode); } // Production callback itself emits only this distinct event and obeys abort/terminal status. $controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'); liveExpect(preg_match('/\'on_live_update\' => (static function.*?\n }),/s', $controller, $matches) === 1, 'production callback is uniquely wired'); $frames = []; $cancel = false; $shouldAbort = static function () use (&$cancel): bool { return $cancel; }; $emit = static function (string $event, array $payload) use (&$frames): bool { $frames[] = $event; return true; }; $timing = new AssistantSseTiming(); $sseStartedAt = hrtime(true); $firstLiveUpdateMs = null; $callback = eval('namespace app\\adminapi\\controller\\tcm; use app\\adminapi\\service\\AssistantSseTiming; return ' . $matches[1] . ';'); liveExpect($callback($updates[0]) && $frames === ['live_update'] && $firstLiveUpdateMs !== null, 'actual controller emits live_update and records first validated arrival'); $cancel = true; liveExpect(!$callback($updates[0]) && $frames === ['live_update'], 'actual controller stops progressive delivery on cancellation'); $timingEvent = $timing->event('upstream_done', ['live_update_count' => 4, 'first_valid_update_ms' => 150, 'live_error_category' => 'citation', 'live_validation_reason' => 'CITATION_INVALID', 'patient_text' => 'private']); liveExpect($timingEvent['live_update_count'] === 4 && $timingEvent['first_valid_update_ms'] === 150 && $timingEvent['live_validation_reason'] === 'CITATION_INVALID' && !isset($timingEvent['patient_text']), 'latency and fixed error enums survive telemetry allowlist, patient text does not'); $unsafeTiming = $timing->event('upstream_done', ['first_valid_update_ms' => 'private', 'live_error_category' => 'private', 'live_validation_reason' => 'private']); liveExpect(!isset($unsafeTiming['first_valid_update_ms'], $unsafeTiming['live_error_category'], $unsafeTiming['live_validation_reason']), 'arbitrary string values cannot masquerade as live timing or validation telemetry'); echo "Live consultation progressive SSE / terminal safety: {$count} total assertions passed (synthetic, no DB/network/model)\n";