true, 'files' => array_map(static fn($f) => ['type' => $f['type'], 'transfer_method' => 'local_file', 'upload_file_id' => $reused ? 'stale-fixture' : 'fresh-fixture'], $files), 'uploaded_indices' => range(1, count($files)), 'skipped' => [], 'reuse_tokens' => [str_repeat('a', 64)], 'reuse_hit_count' => $reused ? count($files) : 0, 'reuse_miss_count' => $reused ? 0 : count($files), 'timings' => ['source_ms' => 1, 'upload_ms' => 2, 'ocr_ms' => 3, 'reuse_wait_ms' => 4]]; } public static function invalidateReuse($tokens, $options): void { ++$GLOBALS['reuseRecoveryInvalidations']; } } function curl_init(): \stdClass { return new \stdClass(); } function curl_setopt_array($h, array $o): bool { $h->options = $o; return true; } function curl_exec($h) { $o = $h->options; $payload = json_decode($o[CURLOPT_POSTFIELDS], true); $GLOBALS['reuseRecoveryRequests'][] = $payload; $stream = ($payload['response_mode'] ?? '') === 'streaming'; $first = count($GLOBALS['reuseRecoveryRequests']) === 1; $scenario = $GLOBALS['reuseRecoveryScenario'] ?? 'stale'; $h->code = $first ? 400 : 200; $h->errno = 0; if ($scenario === 'ambiguous') { $h->code = 503; $h->errno = CURLE_OPERATION_TIMEDOUT; } if ($scenario === 'partial') $h->code = 200; if (isset($o[CURLOPT_HEADERFUNCTION])) ($o[CURLOPT_HEADERFUNCTION])($h, 'HTTP/1.1 ' . $h->code . " Synthetic\r\n"); $body = $first ? json_encode(['code' => 'file_not_found', 'message' => 'synthetic uploaded file not found']) : ($stream ? "data: {\"event\":\"message\",\"answer\":\"synthetic complete reply\"}\n\ndata: {\"event\":\"message_end\"}\n\n" : '{"answer":"synthetic complete reply"}'); if ($scenario === 'partial') $body = "data: {\"event\":\"message\",\"answer\":\"already displayed\"}\n\n" . "data: {\"event\":\"error\",\"code\":\"file_not_found\",\"message\":\"file not found\"}\n\n"; if ($stream) { ($o[CURLOPT_WRITEFUNCTION])($h, $body); return true; } return $body; } function curl_errno($h): int { return $h->errno; } function curl_getinfo($h, $option): int { return $h->code; } function curl_close($h): void {} } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\common\service\DifyChatService; $reuseRecoveryConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, 'max_files' => 20, 'required_attachment_transport' => 'upload', 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-only']]]; foreach ([false, true] as $stream) { $reuseRecoveryScenario = 'stale'; $reuseRecoveryUploads = []; $reuseRecoveryRequests = []; $reuseRecoveryInvalidations = 0; $timings = []; $files = [['type' => 'image', 'url' => 'https://synthetic.invalid/one.png'], ['type' => 'image', 'url' => 'https://synthetic.invalid/two.png']]; $opts = ['required_files' => true, 'knowledge_mode' => 'evidence', 'request_deadline_ns' => hrtime(true) + 5000000000, 'on_attachment_timing' => static function ($m) use (&$timings): void { $timings[] = $m; }]; $deltas = []; $result = $stream ? DifyChatService::streamChat('qwen', [], 'exact original question and full source', 'same-user', static function ($s) use (&$deltas): void { $deltas[] = $s; }, null, $files, $opts) : DifyChatService::chat('qwen', [], 'exact original question and full source', 'same-user', $files, $opts); if (empty($result['ok']) || $reuseRecoveryUploads !== [true, false] || $reuseRecoveryInvalidations !== 1 || count($reuseRecoveryRequests) !== 2 || count($timings) !== 2) throw new RuntimeException('Stale reused handle is reuploaded exactly once'); foreach ($reuseRecoveryRequests as $payload) { if (count($payload['files']) !== 2 || $payload['user'] !== 'same-user' || !str_contains($payload['query'], 'exact original question and full source')) throw new RuntimeException('Fresh retry preserves complete originals and user'); } if (($result['submitted_attachment_count'] ?? 0) !== 2 || str_contains(json_encode($result), str_repeat('a', 64))) { throw new RuntimeException('Complete public receipt never leaks private reuse tokens'); } } foreach (['ambiguous', 'partial', 'cleanup_pending'] as $reuseRecoveryScenario) { $reuseRecoveryUploads = []; $reuseRecoveryRequests = []; $reuseRecoveryInvalidations = 0; $opts['can_start_attempt'] = static fn(): bool => $GLOBALS['reuseRecoveryScenario'] !== 'cleanup_pending' || $GLOBALS['reuseRecoveryRequests'] === []; $result = DifyChatService::streamChat('qwen', [], 'exact original question and full source', 'same-user', static fn(string $s): bool => true, null, $files, $opts); if (count($reuseRecoveryRequests) !== 1 || count($reuseRecoveryUploads) !== 1 || $reuseRecoveryInvalidations !== 0) { throw new RuntimeException('Ambiguous failure, displayed text, or pending cleanup must not reupload/restart'); } } echo "Stale uploaded handle recovery / one fresh original upload / complete source / private metadata: OK\n"; }