Files
zyt/server/tests/DiagnosisAiAssistantTransportCancellationTest.php
2026-10-23 09:26:49 +08:00

121 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace think\facade {
class Log {
public static function warning(string $message): void { $GLOBALS['safeLogs'][] = $message; }
}
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['transportConfig']; }
function curl_init(): \stdClass { return new \stdClass(); }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->options = $options;
$handle->url = $options[CURLOPT_URL];
$handle->body = json_decode($options[CURLOPT_POSTFIELDS], true);
return true;
}
function curl_exec(\stdClass $handle) {
$GLOBALS['requests'][] = ['url' => $handle->url, 'body' => $handle->body];
if (str_ends_with($handle->url, '/stop')) {
if ($GLOBALS['closed'] !== 0) throw new \RuntimeException('Stop must precede generation close');
return json_encode(['result' => $GLOBALS['stopAccepted'] ? 'success' : 'failed']);
}
if (($GLOBALS['mode'] === 'file_rejection' && !empty($handle->body['files']))
|| ($GLOBALS['mode'] === 'protocol_fallback' && str_ends_with($handle->url, 'chat-messages'))) {
$handle->code = $GLOBALS['mode'] === 'file_rejection' ? 400 : 404;
($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Rejected\r\n");
return true;
}
($handle->options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 OK\r\n");
$progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION;
$progress = $handle->options[$progressOption];
$write = $handle->options[CURLOPT_WRITEFUNCTION];
$progress($handle, 0, 0, 100, 100);
$mode = $GLOBALS['mode'];
if ($mode === 'before_metadata') $GLOBALS['cancelled'] = true;
if (!str_contains($handle->url, 'chat/completions')) {
$write($handle, "data: {\"event\":\"workflow_started\",\"task_id\":\"synthetic-task\"}\n\n");
}
if ($mode === 'before_text') $GLOBALS['cancelled'] = true;
if ($progress($handle, 0, 0, 100, 100)) return false;
$write($handle, str_contains($handle->url, 'chat/completions')
? "data: {\"choices\":[{\"delta\":{\"content\":\"合成回答\"}}]}\n\n"
: "data: {\"event\":\"message\",\"task_id\":\"synthetic-task\",\"answer\":\"合成回答\"}\n\n");
if ($mode === 'after_text') $GLOBALS['cancelled'] = true;
if ($progress($handle, 0, 0, 100, 100)) return false;
$write($handle, str_contains($handle->url, 'chat/completions') ? "data: [DONE]\n\n" : "data: {\"event\":\"message_end\"}\n\n");
return true;
}
function curl_errno(\stdClass $handle): int { return 0; }
function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; }
function curl_close(\stdClass $handle): void { if (!str_ends_with($handle->url, '/stop')) ++$GLOBALS['closed']; }
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
function transportExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); }
$transportConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5,
'max_files' => 2, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]];
$run = static function (string $scenario, bool $accept = true, bool $blocking = false) {
$GLOBALS['mode'] = $scenario; $GLOBALS['cancelled'] = false; $GLOBALS['requests'] = [];
$GLOBALS['closed'] = 0; $GLOBALS['stopAccepted'] = $accept;
$progress = []; $deltas = []; $tasks = []; $stops = []; $ends = 0;
$abort = static fn (): bool => $GLOBALS['cancelled'];
$options = ['knowledge_mode' => 'evidence', 'source_summary' => ['diagnosis_count' => 4],
'source_form' => 'evidence_summary', 'evidence_cache_hit' => true,
'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; },
'on_task' => static function (string $task) use (&$tasks): void { $tasks[] = $task; },
'on_stop' => static function (bool $ok) use (&$stops): void { $stops[] = $ok; },
'on_transfer_end' => static function () use (&$ends): void { ++$ends; }];
$files = array_map(static fn (int $i): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $i . '.png'], [1, 2, 3]);
$delta = static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; };
$result = $blocking
? DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', [], $options + ['should_abort' => $abort])
: DifyChatService::streamChat('qwen', [], '合成资料', 'synthetic-user', $delta, $abort, $files, $options);
return compact('result', 'progress', 'deltas', 'tasks', 'stops', 'ends');
};
foreach (['before_metadata', 'before_text', 'after_text'] as $scenario) {
$test = $run($scenario);
transportExpect(($test['result']['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED', $scenario . ': cancellation returned');
transportExpect($test['stops'] === [true] && $test['ends'] === 1, $scenario . ': stop accepted and transfer ended');
transportExpect(count($requests) === 2 && str_ends_with($requests[1]['url'], '/synthetic-task/stop'), $scenario . ': exactly one generation and one stop; no retry');
transportExpect($requests[1]['body'] === ['user' => 'synthetic-user'], 'stop uses exact original user');
transportExpect(count($test['deltas']) === ($scenario === 'after_text' ? 1 : 0), 'no post-cancel text is delivered');
transportExpect(!str_contains(json_encode([$test['result'], $test['progress']]), 'synthetic-task'), 'no task identity leaks to progress or response');
}
$test = $run('before_text', false);
transportExpect($test['stops'] === [false], 'unaccepted stop is never reported as confirmed');
$test = $run('before_text', true, true);
transportExpect($test['stops'] === [true] && $requests[0]['body']['response_mode'] === 'streaming', 'cancellable blocking preprocessing uses stoppable Dify streaming');
transportExpect($test['progress'][0]['source_form'] === 'evidence_summary', 'progress identifies cached evidence rather than full raw source');
$test = $run('success');
transportExpect(!empty($test['result']['ok']), 'noncancelled stream preserves success');
$submitted = array_values(array_filter($test['progress'], static fn (array $p): bool => ($p['phase'] ?? '') === 'attachments' && $p['status'] === 'submitted'))[0];
transportExpect($submitted['attachment_count'] === 2 && count($submitted['items']) === 3 && $submitted['items'][2]['status'] === 'omitted', 'actual file limit is reflected without claiming omitted file submission');
transportExpect($submitted['source_summary']['diagnosis_count'] === 4 && $submitted['attachment_transfer'] === 'remote_url_references', 'real source counts and URL transfer form are shown');
transportExpect(!str_contains(json_encode($submitted), 'https://'), 'safe labels never disclose remote patient URLs');
$test = $run('file_rejection');
$attempts = array_values(array_filter($test['progress'], static fn (array $p): bool => ($p['phase'] ?? '') === 'attachments'));
$last = end($attempts);
transportExpect(!empty($test['result']['ok']) && count($requests) === 2 && $last['attachment_count'] === 0
&& array_unique(array_column($last['items'], 'status')) === ['omitted'], 'file rejection updates final attempt to zero inline files and all omitted');
transportExpect(in_array('failed', array_column($attempts, 'status'), true), 'rejected submission has an explicit failed state before fallback');
$transportConfig['base_url'] = 'https://synthetic.invalid/v1';
$test = $run('protocol_fallback');
transportExpect(!empty($test['result']['ok']) && count($requests) === 2
&& str_ends_with($requests[1]['url'], '/chat/completions'), 'progress follows the actual protocol fallback attempt');
$transportConfig['base_url'] = 'https://synthetic.invalid/v1/chat/completions';
$test = $run('after_text');
transportExpect(count($requests) === 1 && $test['stops'] === [false] && $test['ends'] === 1, 'OpenAI cancellation closes transport without inventing a Dify stop');
$requests = [];
$neverStarted = DifyChatService::streamChat('qwen', [], '合成资料', 'synthetic-user', static fn (): bool => true,
static fn (): bool => true, [], ['knowledge_mode' => 'evidence']);
transportExpect($neverStarted['error_code'] === 'AI_ASSISTANT_CANCELLED' && $requests === [], 'cancellation before any upstream work sends no generation request');
echo "Diagnosis AI assistant cancellable transport / task stop / real progress: OK\n";
}