## Development lane / 开发通道 - Lane: `direct-to-main` bootstrap - Base branch: `main` - Active train: `n/a` - Owning Agent/session: workflow coordinator ## Change / 改动 - Split authoritative CI into parallel static, contract, Rust lint/test shards, Swift interaction, stress, overlay, bundle, and stable `Required CI` jobs. - Make every ready PR to `main` a complete gate while keeping task PRs to `gd-ops/train/*` path-scoped. - Add direct/train worktree coordination, bounded changelog fragments, protected main/train ruleset automation, and safe same-repository squash auto-merge. - Let GitHub Release promote an exact successful main commit, reuse its source proof, and create a missing release tag only after proof validation. - Keep local pre-push validation bounded to basic syntax, formatting, contracts, and compilation; complete duplicate proof stays in GitHub CI. This is the one bootstrap PR: the auto-merge workflow and required rules do not exist on the base branch yet. After this PR passes and is merged once, the exact `main` push must pass `Required CI`; then the fail-closed applicator can activate both rulesets and all later ready `gd-ops/*` PRs follow the automated path. ## Validation / 验证 - `actionlint .github/workflows/ci.yml .github/workflows/release.yml .github/workflows/auto-merge.yml` - `./script/validate_source_syntax.sh` - `./script/validate_build_scripts_safety.sh --skip-source-syntax` - Exact staged diff was reapplied to a clean detached worktree before the final validation run. - 28 validation-tooling tests, 40 release-distribution tests, macOS build-contract tests, Release shell contracts, and test-isolation validation passed. ## Risk / 风险 - No product runtime or credential access is added. - `pull_request_target` never checks out or executes PR code and only enables auto-merge after API verification of active PR and `Required CI` rules. - Full ruleset activation fails closed until the exact remote main commit has a trusted successful GitHub Actions push run. - Reversible repository settings are already enabled: auto-merge, squash-only, update branch, and delete merged branches. --------- Co-authored-by: gd-ops <gd-ops@xmfunny.com>
5999 lines
211 KiB
Rust
5999 lines
211 KiB
Rust
use image::{ImageBuffer, Rgba};
|
|
use petcore::adapter_contracts::{
|
|
CLAUDE_HOOKS_CONTRACT_VERSION, OPENCODE_CONTRACT_VERSION, PI_EXTENSION_CONTRACT_VERSION,
|
|
};
|
|
use petcore::connections;
|
|
use petcore::daemon;
|
|
use petcore::db::Database;
|
|
use petcore::generation;
|
|
use petcore::launch_agent::{self, LaunchAgentConfig};
|
|
use petcore::paths::AppPaths;
|
|
use petcore::petpack::{build_petpack, validate_petpack_path, write_sample_petpack_dir};
|
|
use petcore::rpc::{handle_request, CoreState, RpcRequest};
|
|
use petcore_types::{
|
|
AgentEvent, AgentEventType, AgentSource, BehaviorSettings, CheckStatus, GenerationForm,
|
|
GenerationJobStatus, InterfaceLanguage, OverlayPlacement, PetSummary, QualityLevel,
|
|
SessionGroupDisplay,
|
|
};
|
|
use rustix::io::Errno;
|
|
use rustix::process::{getpgrp, test_kill_process_group, Pid};
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use std::ffi::OsString;
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::os::unix::net::UnixListener;
|
|
use std::os::unix::prelude::PermissionsExt;
|
|
use std::path::Path;
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::{mpsc, Mutex};
|
|
use std::time::{Duration, Instant, SystemTime};
|
|
use time::{format_description::well_known::Rfc3339, Duration as TimeDuration, OffsetDateTime};
|
|
|
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
original: Option<OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
|
let original = std::env::var_os(key);
|
|
std::env::set_var(key, value);
|
|
Self { key, original }
|
|
}
|
|
|
|
fn remove(key: &'static str) -> Self {
|
|
let original = std::env::var_os(key);
|
|
std::env::remove_var(key);
|
|
Self { key, original }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(value) = &self.original {
|
|
std::env::set_var(self.key, value);
|
|
} else {
|
|
std::env::remove_var(self.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn projected_event_id(value: &str) -> String {
|
|
let mut digest = Sha256::new();
|
|
digest.update(b"agent-pet-companion/overlay-identity/v1\0");
|
|
digest.update(b"event\0");
|
|
digest.update(value.as_bytes());
|
|
format!("evt-{}", hex::encode(digest.finalize()))
|
|
}
|
|
|
|
fn wait_for_fixture_process_group(path: &Path) -> Pid {
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
if let Ok(raw) = std::fs::read_to_string(path) {
|
|
if let Some(process_group) = raw
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.and_then(|value| value.parse::<i32>().ok())
|
|
.and_then(Pid::from_raw)
|
|
{
|
|
return process_group;
|
|
}
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"fake App Server did not publish its private process group"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
fn wait_for_generation_worker_cleanup(session_path: &Path, process_group: Pid) {
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let process_group_gone = match test_kill_process_group(process_group) {
|
|
Err(Errno::SRCH) => true,
|
|
// Match the production cleanup's fail-closed rule: Darwin can
|
|
// briefly report EPERM while the group leader is being reaped.
|
|
// Only ESRCH proves that the complete private group is gone.
|
|
Ok(()) | Err(_) => false,
|
|
};
|
|
if session_path.is_file() && process_group_gone {
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation worker cleanup did not finish; session_written={}, process_group_gone={process_group_gone}",
|
|
session_path.is_file()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
fn assert_matches_petpack_metadata_schema(schema_name: &str, instance: &serde_json::Value) {
|
|
let schema_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../schemas")
|
|
.join(schema_name);
|
|
let schema: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&schema_path).unwrap()).unwrap();
|
|
let validator = jsonschema::options()
|
|
.with_draft(jsonschema::Draft::Draft202012)
|
|
.should_validate_formats(true)
|
|
.build(&schema)
|
|
.unwrap();
|
|
let diagnostics = validator
|
|
.iter_errors(instance)
|
|
.map(|error| error.to_string())
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
diagnostics.is_empty(),
|
|
"{} rejected generated metadata: {}",
|
|
schema_path.display(),
|
|
diagnostics.join("; ")
|
|
);
|
|
}
|
|
|
|
fn write_fake_app_server_script(path: &Path, thread_id: &str) {
|
|
write_fake_app_server_script_with_marker(path, thread_id, None);
|
|
}
|
|
|
|
fn write_fake_app_server_script_with_marker(
|
|
path: &Path,
|
|
thread_id: &str,
|
|
process_file: Option<&Path>,
|
|
) {
|
|
// Keep process evidence bound to this fixture script. A process-global
|
|
// marker variable can be inherited by an older asynchronous test worker
|
|
// after the next test has already replaced the environment.
|
|
let process_marker = process_file.map_or_else(String::new, |process_file| {
|
|
let quoted_path = format!(
|
|
"'{}'",
|
|
process_file.display().to_string().replace('\'', "'\\''")
|
|
);
|
|
format!(
|
|
"app_server_pgid=\"$(ps -o pgid= -p \"$$\")\"\nset -- $app_server_pgid\nprintf '%s %s\\n' \"$$\" \"$1\" > {quoted_path}"
|
|
)
|
|
});
|
|
let mut file = std::fs::File::create(path).unwrap();
|
|
writeln!(
|
|
file,
|
|
r#"#!/bin/sh
|
|
{process_marker}
|
|
while IFS= read -r request; do
|
|
case "$request" in
|
|
*initialize*)
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"serverInfo":{{"name":"fake-codex-app-server","petcoreCli":"'"$APC_PETCORE_CLI"'"}}}}}}'
|
|
;;
|
|
*thread/start*)
|
|
printf '%s\n' '{{"method":"remoteControl/status/changed","params":{{"status":"disabled"}}}}'
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"thread":{{"id":"{thread_id}","sessionId":"{thread_id}","ephemeral":false,"status":{{"type":"idle"}},"cwd":"/tmp","turns":[]}},"model":"fake-model","modelProvider":"fake","cwd":"/tmp","approvalPolicy":"never","sandbox":{{"type":"workspaceWrite"}}}}}}'
|
|
;;
|
|
*thread/resume*)
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"thread":{{"id":"{thread_id}","sessionId":"{thread_id}","ephemeral":false,"status":{{"type":"idle"}},"cwd":"/tmp","turns":[{{"id":"turn_fake_pet_studio","status":"completed"}}]}},"model":"fake-model","modelProvider":"fake","cwd":"/tmp","approvalPolicy":"never","sandbox":{{"type":"readOnly"}}}}}}'
|
|
;;
|
|
*thread/archive*)
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{}}}}'
|
|
;;
|
|
*turn/start*)
|
|
request_id="${{request#*\"id\":}}"
|
|
request_id="${{request_id%%,*}}"
|
|
case "$request_id" in
|
|
''|*[!0-9]*) request_id=3 ;;
|
|
esac
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":'"$request_id"',"result":{{"turn":{{"id":"turn_fake_pet_studio","items":[],"itemsView":"notLoaded","status":"inProgress","error":null}}}}}}'
|
|
printf '%s\n' '{{"method":"turn/started","params":{{"threadId":"{thread_id}","turn":{{"id":"turn_fake_pet_studio","status":"inProgress"}}}}}}'
|
|
printf '%s\n' '{{"method":"item/agentMessage/delta","params":{{"threadId":"{thread_id}","turnId":"turn_fake_pet_studio","itemId":"msg_fake","delta":"{{\"name\":\"AI 云袖\",\"visual_brief\":\"AI brief\",\"palette\":[\"pearl\",\"ink\",\"cyan\"],\"states\":[{{\"name\":\"idle\",\"motion\":\"breathing\"}}],\"render_notes\":\"transparent PNG\"}}"}}}}'
|
|
if [ -n "${{APC_FAKE_APP_SERVER_WAIT_FILE:-}}" ]; then
|
|
while [ ! -f "$APC_FAKE_APP_SERVER_WAIT_FILE" ]; do
|
|
sleep 0.05
|
|
done
|
|
fi
|
|
printf '%s\n' '{{"method":"item/completed","params":{{"threadId":"{thread_id}","turnId":"turn_fake_pet_studio","item":{{"type":"agentMessage","id":"msg_fake","text":"{{\"name\":\"AI 云袖\",\"visual_brief\":\"AI brief\",\"palette\":[\"pearl\",\"ink\",\"cyan\"],\"states\":[{{\"name\":\"idle\",\"motion\":\"breathing\"}}],\"render_notes\":\"transparent PNG\"}}","phase":"final_answer"}}}}}}'
|
|
printf '%s\n' '{{"method":"turn/completed","params":{{"threadId":"{thread_id}","turn":{{"id":"turn_fake_pet_studio","status":"completed"}}}}}}'
|
|
;;
|
|
esac
|
|
done
|
|
"#
|
|
)
|
|
.unwrap();
|
|
let mut permissions = std::fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
|
|
fn write_fake_app_server_input_request_script(path: &Path, thread_id: &str) {
|
|
let mut file = std::fs::File::create(path).unwrap();
|
|
writeln!(
|
|
file,
|
|
r#"#!/bin/sh
|
|
resumed=0
|
|
while IFS= read -r request; do
|
|
case "$request" in
|
|
*initialize*)
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"serverInfo":{{"name":"fake-codex-app-server","petcoreCli":"'"$APC_PETCORE_CLI"'"}}}}}}'
|
|
;;
|
|
*thread/start*)
|
|
resumed=0
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"thread":{{"id":"{thread_id}","sessionId":"{thread_id}","ephemeral":false,"status":{{"type":"idle"}},"cwd":"/tmp","turns":[]}},"model":"fake-model","modelProvider":"fake","cwd":"/tmp","approvalPolicy":"never","sandbox":{{"type":"workspaceWrite"}}}}}}'
|
|
;;
|
|
*thread/resume*)
|
|
resumed=1
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"thread":{{"id":"{thread_id}","sessionId":"{thread_id}","ephemeral":false,"status":{{"type":"idle"}},"cwd":"/tmp","turns":[{{"id":"turn_input_request","status":"completed"}}]}},"model":"fake-model","modelProvider":"fake","cwd":"/tmp","approvalPolicy":"never","sandbox":{{"type":"readOnly"}}}}}}'
|
|
;;
|
|
*turn/start*)
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":3,"result":{{"turn":{{"id":"turn_fake_pet_studio","items":[],"itemsView":"notLoaded","status":"inProgress","error":null}}}}}}'
|
|
printf '%s\n' '{{"method":"turn/started","params":{{"threadId":"{thread_id}","turn":{{"id":"turn_fake_pet_studio","status":"inProgress"}}}}}}'
|
|
if [ "$resumed" = "1" ]; then
|
|
printf '%s\n' '{{"method":"item/completed","params":{{"threadId":"{thread_id}","turnId":"turn_fake_pet_studio","item":{{"type":"agentMessage","id":"msg_fake","text":"{{\"name\":\"追问完成宠物\",\"visual_brief\":\"补充后的 coherent pet brief\",\"palette\":[\"pearl\",\"ink\",\"cyan\"],\"states\":[{{\"name\":\"idle\",\"motion\":\"breathing\"}}],\"render_notes\":\"transparent PNG\"}}","phase":"final_answer"}}}}}}'
|
|
else
|
|
printf '%s\n' '{{"method":"item/completed","params":{{"threadId":"{thread_id}","turnId":"turn_fake_pet_studio","item":{{"type":"agentMessage","id":"msg_fake","text":"{{\"needs_input\":true,\"question\":\"请补充这个桌宠的主体外观。\"}}","phase":"final_answer"}}}}}}'
|
|
fi
|
|
printf '%s\n' '{{"method":"turn/completed","params":{{"threadId":"{thread_id}","turn":{{"id":"turn_fake_pet_studio","status":"completed"}}}}}}'
|
|
;;
|
|
esac
|
|
done
|
|
"#
|
|
)
|
|
.unwrap();
|
|
let mut permissions = std::fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
|
|
fn write_fake_cli(path: &Path) {
|
|
let mut file = std::fs::File::create(path).unwrap();
|
|
writeln!(
|
|
file,
|
|
r#"#!/bin/sh
|
|
set -eu
|
|
: "${{APC_FAKE_CLI_CAPTURE:?}}"
|
|
{{
|
|
printf 'ARGV:%s\n' "$*"
|
|
printf 'STDIN:'
|
|
cat
|
|
printf '\n---\n'
|
|
}} >> "$APC_FAKE_CLI_CAPTURE"
|
|
"#
|
|
)
|
|
.unwrap();
|
|
let mut permissions = std::fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
|
|
fn node_available() -> bool {
|
|
Command::new("node")
|
|
.arg("--version")
|
|
.output()
|
|
.map(|output| output.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn run_node_module_smoke(script: &str, capture_path: &Path) {
|
|
if !node_available() {
|
|
eprintln!("node is not available; skipping connector runtime smoke");
|
|
return;
|
|
}
|
|
|
|
let output = Command::new("node")
|
|
.arg("--input-type=module")
|
|
.arg("--eval")
|
|
.arg(script)
|
|
.env("APC_FAKE_CLI_CAPTURE", capture_path)
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"node smoke failed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn run_shell_hook_smoke(command: &str, stdin: &str, capture_path: &Path) {
|
|
let mut child = Command::new("sh")
|
|
.arg("-lc")
|
|
.arg(command)
|
|
.env("APC_FAKE_CLI_CAPTURE", capture_path)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
if let Some(mut child_stdin) = child.stdin.take() {
|
|
child_stdin.write_all(stdin.as_bytes()).unwrap();
|
|
}
|
|
let output = child.wait_with_output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"shell hook failed\ncommand:\n{command}\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn wait_for_capture(path: &Path, needle: &str) -> String {
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
loop {
|
|
let content = std::fs::read_to_string(path).unwrap_or_default();
|
|
if content.contains(needle) {
|
|
return content;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"connector capture did not contain {needle:?}; current capture:\n{content}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
fn wait_for_file(path: &Path) {
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
loop {
|
|
if path.exists() {
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"expected file was not created: {}",
|
|
path.display()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
fn http_post_agent_event(
|
|
port: u16,
|
|
token: Option<&str>,
|
|
body: &serde_json::Value,
|
|
) -> (u16, serde_json::Value) {
|
|
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
|
let body = serde_json::to_string(body).unwrap();
|
|
let token_header = token
|
|
.map(|token| format!("X-Agent-Pet-Token: {token}\r\n"))
|
|
.unwrap_or_default();
|
|
write!(
|
|
stream,
|
|
"POST /agent-events HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n{}",
|
|
body.len(),
|
|
token_header,
|
|
body
|
|
)
|
|
.unwrap();
|
|
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response).unwrap();
|
|
let (headers, body) = response
|
|
.split_once("\r\n\r\n")
|
|
.expect("HTTP response should include headers and body");
|
|
let status = headers
|
|
.lines()
|
|
.next()
|
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
.and_then(|status| status.parse::<u16>().ok())
|
|
.expect("HTTP response should include status code");
|
|
let body = serde_json::from_str(body).unwrap();
|
|
(status, body)
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_missing_state() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
std::fs::remove_dir_all(temp.path().join("assets/frames/tool")).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("tool"));
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_missing_animated_preview() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
std::fs::remove_file(temp.path().join("assets/preview/animated_preview.webp")).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("animated_preview.webp"));
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_missing_source_metadata() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
std::fs::remove_file(temp.path().join("source/source.json")).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("source/source.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_missing_skill_session_metadata() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
std::fs::remove_file(temp.path().join("source/skill_session.jsonl")).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("source/skill_session.jsonl"));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_petpack_metadata_rejects_nested_private_session_fields_without_echoing_values() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let source_path = temp.path().join("source/source.json");
|
|
let mut source: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&source_path).unwrap()).unwrap();
|
|
source["ai_brief"] = json!({
|
|
"creative": {
|
|
"thread_id": "thread_private_should_never_be_reported"
|
|
}
|
|
});
|
|
std::fs::write(&source_path, serde_json::to_vec_pretty(&source).unwrap()).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("privacy check rejected"));
|
|
assert!(error.contains("thread_id"));
|
|
assert!(!error.contains("thread_private_should_never_be_reported"));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_petpack_metadata_rejects_nested_absolute_paths_without_echoing_values() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let brief_path = temp.path().join("brief.json");
|
|
let mut brief: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&brief_path).unwrap()).unwrap();
|
|
brief["ai_brief"] = json!({
|
|
"visual": {
|
|
"reference": "/Users/private-user/secret-reference.png"
|
|
}
|
|
});
|
|
std::fs::write(&brief_path, serde_json::to_vec_pretty(&brief).unwrap()).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("absolute local path"));
|
|
assert!(!error.contains("private-user"));
|
|
assert!(!error.contains("secret-reference"));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_petpack_metadata_rejects_embedded_paths_and_external_locators() {
|
|
for (payload, category, secret) in [
|
|
(
|
|
"reference(/Users/private-user/embedded-secret.png)",
|
|
"absolute local path",
|
|
"private-user",
|
|
),
|
|
(
|
|
"路径/Users/private-user/embedded-secret.png",
|
|
"absolute local path",
|
|
"private-user",
|
|
),
|
|
(
|
|
"reference(https://private.example.invalid/embedded-secret.png)",
|
|
"external locator",
|
|
"private.example.invalid",
|
|
),
|
|
] {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let brief_path = temp.path().join("brief.json");
|
|
let mut brief: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&brief_path).unwrap()).unwrap();
|
|
brief["ai_brief"] = json!({ "note": payload });
|
|
std::fs::write(&brief_path, serde_json::to_vec_pretty(&brief).unwrap()).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains(category), "{error}");
|
|
assert!(!error.contains(secret), "{error}");
|
|
assert!(!error.contains("embedded-secret"), "{error}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_petpack_metadata_rejects_namespaced_and_affixed_private_fields() {
|
|
for key in [
|
|
"dev.example/thread_id",
|
|
"dev.example/api_key_backup",
|
|
"metadata_thread_id",
|
|
"thread_id_backup",
|
|
"metadataThreadId",
|
|
"threadIdBackup",
|
|
"metadataSessionIdBackup",
|
|
"threadidbackup",
|
|
"backupsessionid",
|
|
"dev.example/threadidbackup",
|
|
"backupapikey",
|
|
] {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let source_path = temp.path().join("source/source.json");
|
|
let mut source: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&source_path).unwrap()).unwrap();
|
|
let mut private = serde_json::Map::new();
|
|
private.insert(key.to_string(), json!("private-session-value"));
|
|
source["ai_brief"] = serde_json::Value::Object(private);
|
|
std::fs::write(&source_path, serde_json::to_vec_pretty(&source).unwrap()).unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("forbidden private field"), "{key}: {error}");
|
|
assert!(!error.contains("private-session-value"), "{key}: {error}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_petpack_metadata_allows_path_like_prose_and_non_private_words() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let brief_path = temp.path().join("brief.json");
|
|
let mut brief: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&brief_path).unwrap()).unwrap();
|
|
brief["ai_brief"] = json!({
|
|
"note": "Animate idle/start/tool at 10/20 fps; use / as a separator and (https-inspired) highlights.",
|
|
"reference_note": "reference(images/moon.png) and assets/frames/idle/frame_000.png",
|
|
"authentic_style": "storybook",
|
|
"commanding_motion": "confident pose",
|
|
"environmental_lighting": "soft rim light",
|
|
"secretary_note": "friendly expression",
|
|
"threadlike_pattern": "fine silver embroidery",
|
|
"tokenized_palette": "violet and pearl"
|
|
});
|
|
std::fs::write(&brief_path, serde_json::to_vec_pretty(&brief).unwrap()).unwrap();
|
|
|
|
let validation = validate_petpack_path(temp.path()).unwrap();
|
|
assert!(validation.ok);
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_failed_build_metadata() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
temp.path().join("build/validation.json"),
|
|
serde_json::to_vec_pretty(&json!({ "ok": false })).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("build/validation.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_escaping_asset_paths() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let manifest_path = temp.path().join("manifest.json");
|
|
let mut manifest: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
|
|
manifest["states"][0]["frames_dir"] = serde_json::Value::String("../outside".to_string());
|
|
std::fs::write(
|
|
&manifest_path,
|
|
serde_json::to_vec_pretty(&manifest).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("inside the package"));
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_validation_rejects_codex_compatibility_package_markers() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_sample_petpack_dir(
|
|
temp.path(),
|
|
QualityLevel::Standard,
|
|
"Cloud Maiden",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(temp.path().join("codex-pet.json"), "{}").unwrap();
|
|
|
|
let error = validate_petpack_path(temp.path()).unwrap_err().to_string();
|
|
assert!(error.contains("must not include Codex compatibility package marker"));
|
|
}
|
|
|
|
#[test]
|
|
fn petpack_build_rejects_source_symlink() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let source = temp.path().join("source");
|
|
write_sample_petpack_dir(&source, QualityLevel::Standard, "Cloud Maiden", "半写实").unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(
|
|
source.join("manifest.json"),
|
|
source.join("source").join("manifest-link.json"),
|
|
)
|
|
.unwrap();
|
|
let error = build_petpack(&source, &temp.path().join("unsafe.petpack"))
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(error.contains("must not contain symlink"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rpc_ingest_deduplicates_and_filters_events() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths.clone());
|
|
state.ensure_ready().unwrap();
|
|
assert!(state.database.behavior().unwrap().mouse_passthrough);
|
|
|
|
let request = |params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "agent.ingest".to_string(),
|
|
params,
|
|
};
|
|
|
|
let first = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_test_same",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(first["inserted"], true);
|
|
assert_eq!(first["triggered"], true);
|
|
|
|
let duplicate = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_test_same",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(duplicate["inserted"], false);
|
|
assert_eq!(duplicate["triggered"], false);
|
|
|
|
let database = Database::new(state.paths.db_path.clone());
|
|
let mut behavior = BehaviorSettings::default();
|
|
behavior.sources.insert(AgentSource::Codex, false);
|
|
database.set_setting("behavior", &behavior).unwrap();
|
|
|
|
let filtered = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_test_filtered",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(filtered["inserted"], true);
|
|
assert_eq!(filtered["triggered"], false);
|
|
|
|
behavior.sources.insert(AgentSource::Codex, true);
|
|
behavior.events.insert(AgentEventType::Tool, false);
|
|
database.set_setting("behavior", &behavior).unwrap();
|
|
|
|
let event_filtered = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_test_event_filtered",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(event_filtered["inserted"], true);
|
|
assert_eq!(event_filtered["triggered"], false);
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(snapshot["events"].as_array().unwrap().len(), 0);
|
|
|
|
let recent = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "events.recent".to_string(),
|
|
params: json!({ "limit": 10 }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(recent.as_array().unwrap().len(), 3);
|
|
|
|
database
|
|
.set_setting("behavior", &BehaviorSettings::default())
|
|
.unwrap();
|
|
let diagnostic = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_test_diagnostic",
|
|
"source": "codex",
|
|
"event_type": "done",
|
|
"title": "连接自检",
|
|
"payload": {
|
|
"diagnostic": true
|
|
}
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(diagnostic["inserted"], true);
|
|
assert_eq!(diagnostic["triggered"], false);
|
|
|
|
let diagnostic_snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let overlay_events = diagnostic_snapshot["events"].as_array().unwrap();
|
|
assert!(overlay_events
|
|
.iter()
|
|
.all(|event| event["id"] != projected_event_id("evt_test_diagnostic")));
|
|
|
|
let activity = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_activity_before_metadata",
|
|
"source": "claude_code",
|
|
"session_id": "metadata-overlay-session",
|
|
"event_type": "tool",
|
|
"title": "执行工具",
|
|
"payload": {
|
|
"source_event": "PreToolUse",
|
|
"affects_activity": true,
|
|
"session_active": true
|
|
}
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(activity["triggered"], true);
|
|
|
|
let metadata = handle_request(
|
|
&state,
|
|
request(json!({
|
|
"id": "evt_metadata_after_activity",
|
|
"source": "claude_code",
|
|
"session_id": "metadata-overlay-session",
|
|
"event_type": "start",
|
|
"title": "配置已变化",
|
|
"payload": {
|
|
"source_event": "ConfigChange",
|
|
"affects_activity": false,
|
|
"session_active": true
|
|
}
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(metadata["inserted"], true);
|
|
assert_eq!(metadata["triggered"], false);
|
|
|
|
let metadata_snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(metadata_snapshot["active_agent_state"]["state"], "tool");
|
|
assert_eq!(
|
|
metadata_snapshot["active_agent_state"]["event"]["id"],
|
|
projected_event_id("evt_activity_before_metadata")
|
|
);
|
|
assert!(metadata_snapshot["events"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|event| event["id"] != projected_event_id("evt_metadata_after_activity")));
|
|
|
|
let recent_with_diagnostic = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "events.recent".to_string(),
|
|
params: json!({ "limit": 10 }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let history = recent_with_diagnostic.as_array().unwrap();
|
|
assert_eq!(history.len(), 6);
|
|
assert!(history
|
|
.iter()
|
|
.any(|event| event["id"] == "evt_test_diagnostic"));
|
|
}
|
|
|
|
#[test]
|
|
fn connector_receipts_are_source_scoped_and_probe_lookup_survives_later_diagnostics() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let agent_home = temp.path().join("agent-home");
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
write_fake_cli(&fake_cli);
|
|
let _agent_home = EnvVarGuard::set("APC_AGENT_CONFIG_HOME", &agent_home);
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", &fake_cli);
|
|
let _pi_cli = EnvVarGuard::set("APC_PI_CLI_PATH", temp.path().join("missing-pi"));
|
|
let paths = AppPaths::new(temp.path().join("app-home"));
|
|
paths.ensure().unwrap();
|
|
connections::repair_source_at(&paths, AgentSource::Pi, temp.path()).unwrap();
|
|
let state = CoreState::new(paths.clone());
|
|
state.ensure_ready().unwrap();
|
|
|
|
let ingest = |id: &str, source_event: &str, diagnostic: bool, contract_version: &str| {
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!(id)),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": id,
|
|
"source": "pi",
|
|
"session_id": "pi-receipt-session",
|
|
"event_type": "start",
|
|
"title": "连接事件",
|
|
"payload": {
|
|
"source_event": source_event,
|
|
"contract_version": contract_version,
|
|
"diagnostic": diagnostic,
|
|
"affects_activity": !diagnostic
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
};
|
|
|
|
ingest(
|
|
"evt_pi_ordinary",
|
|
"turn_start",
|
|
false,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
);
|
|
ingest(
|
|
"evt_pi_probe",
|
|
"connector.probe",
|
|
true,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
);
|
|
ingest(
|
|
"evt_pi_later_diagnostic",
|
|
"session_shutdown",
|
|
true,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
);
|
|
ingest(
|
|
"evt_pi_stale_ordinary",
|
|
"turn_end",
|
|
false,
|
|
"pi-extension-stale",
|
|
);
|
|
ingest(
|
|
"evt_pi_stale_probe",
|
|
"connector.probe",
|
|
true,
|
|
"pi-extension-stale",
|
|
);
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("other-session-followup")),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": "evt_pi_other_session_followup",
|
|
"source": "pi",
|
|
"session_id": "pi-other-session",
|
|
"event_type": "done",
|
|
"title": "连接事件",
|
|
"payload": {
|
|
"source_event": "agent_settled",
|
|
"contract_version": PI_EXTENSION_CONTRACT_VERSION,
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let probe = state
|
|
.database
|
|
.latest_connector_probe_receipt_for_contract(AgentSource::Pi, PI_EXTENSION_CONTRACT_VERSION)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(probe.source_event, "connector.probe");
|
|
assert_eq!(
|
|
probe.contract_version.as_deref(),
|
|
Some(PI_EXTENSION_CONTRACT_VERSION)
|
|
);
|
|
assert!(state
|
|
.database
|
|
.connector_event_was_received(
|
|
AgentSource::Pi,
|
|
"pi-receipt-session",
|
|
"connector.probe",
|
|
true,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
)
|
|
.unwrap());
|
|
assert!(!state
|
|
.database
|
|
.connector_event_was_received(
|
|
AgentSource::Pi,
|
|
"different-concurrent-probe",
|
|
"connector.probe",
|
|
true,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
)
|
|
.unwrap());
|
|
|
|
let receipts = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("receipts")),
|
|
method: "connections.receipts".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let pi = receipts
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|entry| entry["source"] == "pi")
|
|
.unwrap();
|
|
assert_eq!(pi["ordinary"]["current"], true);
|
|
assert_eq!(
|
|
pi["ordinary"]["receipt"]["contract_version"],
|
|
PI_EXTENSION_CONTRACT_VERSION
|
|
);
|
|
assert_eq!(pi["ordinary"]["receipt"]["diagnostic"], false);
|
|
assert_eq!(
|
|
pi["latest_observed"]["ordinary"]["contract_version"],
|
|
PI_EXTENSION_CONTRACT_VERSION
|
|
);
|
|
assert_eq!(
|
|
pi["latest_observed"]["diagnostic"]["contract_version"],
|
|
"pi-extension-stale"
|
|
);
|
|
assert_eq!(
|
|
pi["diagnostic"]["receipt"]["source_event"],
|
|
"session_shutdown"
|
|
);
|
|
assert!(
|
|
pi["task"].is_null(),
|
|
"a task start and an unrelated-session terminal must not pass"
|
|
);
|
|
|
|
ingest(
|
|
"evt_pi_task_activity",
|
|
"tool_execution_start",
|
|
false,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
);
|
|
|
|
ingest(
|
|
"evt_pi_task_completion",
|
|
"agent_settled",
|
|
false,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
);
|
|
let receipts = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("task-receipts")),
|
|
method: "connections.receipts".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let pi = receipts
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|entry| entry["source"] == "pi")
|
|
.unwrap();
|
|
assert_eq!(pi["task"]["current"], true);
|
|
assert_eq!(pi["task"]["receipt"]["start"]["source_event"], "turn_start");
|
|
assert_eq!(
|
|
pi["task"]["receipt"]["activity"]["source_event"],
|
|
"tool_execution_start"
|
|
);
|
|
assert_eq!(
|
|
pi["task"]["receipt"]["completion"]["source_event"],
|
|
"agent_settled"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn passive_connector_events_do_not_satisfy_ordinary_event_seen() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
|
|
let insert = |id: &str,
|
|
source: AgentSource,
|
|
session_id: &str,
|
|
event_type: AgentEventType,
|
|
source_event: &str,
|
|
contract_version: &str,
|
|
diagnostic: bool,
|
|
affects_activity: bool| {
|
|
database
|
|
.insert_event(&AgentEvent {
|
|
id: id.to_string(),
|
|
source,
|
|
project_path: None,
|
|
session_id: Some(session_id.to_string()),
|
|
event_type,
|
|
title: event_type.zh_label().to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"source_event": source_event,
|
|
"contract_version": contract_version,
|
|
"diagnostic": diagnostic,
|
|
"affects_activity": affects_activity,
|
|
"session_active": matches!(
|
|
event_type,
|
|
AgentEventType::Start
|
|
| AgentEventType::Thinking
|
|
| AgentEventType::Plan
|
|
| AgentEventType::Tool
|
|
| AgentEventType::Waiting
|
|
)
|
|
}),
|
|
created_at: OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
|
|
})
|
|
.unwrap();
|
|
};
|
|
|
|
insert(
|
|
"evt_claude_passive_start",
|
|
AgentSource::ClaudeCode,
|
|
"claude-passive-start",
|
|
AgentEventType::Start,
|
|
"SessionStart",
|
|
CLAUDE_HOOKS_CONTRACT_VERSION,
|
|
false,
|
|
false,
|
|
);
|
|
// Defense in depth: even an old/malformed payload that marks a pure close
|
|
// edge active cannot become ordinary task evidence.
|
|
insert(
|
|
"evt_claude_passive_end",
|
|
AgentSource::ClaudeCode,
|
|
"claude-passive-end",
|
|
AgentEventType::Done,
|
|
"SessionEnd",
|
|
CLAUDE_HOOKS_CONTRACT_VERSION,
|
|
false,
|
|
true,
|
|
);
|
|
assert!(database
|
|
.latest_connector_ordinary_receipt_for_contract(
|
|
AgentSource::ClaudeCode,
|
|
CLAUDE_HOOKS_CONTRACT_VERSION,
|
|
)
|
|
.unwrap()
|
|
.is_none());
|
|
|
|
insert(
|
|
"evt_opencode_passive_created",
|
|
AgentSource::Opencode,
|
|
"opencode-passive-created",
|
|
AgentEventType::Start,
|
|
"session.created",
|
|
OPENCODE_CONTRACT_VERSION,
|
|
false,
|
|
false,
|
|
);
|
|
insert(
|
|
"evt_opencode_passive_deleted",
|
|
AgentSource::Opencode,
|
|
"opencode-passive-deleted",
|
|
AgentEventType::Done,
|
|
"session.deleted",
|
|
OPENCODE_CONTRACT_VERSION,
|
|
false,
|
|
true,
|
|
);
|
|
for (id, event_type, source_event) in [
|
|
(
|
|
"evt_opencode_close_only_idle",
|
|
AgentEventType::Done,
|
|
"session.idle",
|
|
),
|
|
(
|
|
"evt_opencode_close_only_status",
|
|
AgentEventType::Done,
|
|
"session.status",
|
|
),
|
|
(
|
|
"evt_opencode_close_only_error",
|
|
AgentEventType::Failed,
|
|
"session.error",
|
|
),
|
|
] {
|
|
insert(
|
|
id,
|
|
AgentSource::Opencode,
|
|
id,
|
|
event_type,
|
|
source_event,
|
|
OPENCODE_CONTRACT_VERSION,
|
|
false,
|
|
true,
|
|
);
|
|
}
|
|
assert!(database
|
|
.latest_connector_ordinary_receipt_for_contract(
|
|
AgentSource::Opencode,
|
|
OPENCODE_CONTRACT_VERSION,
|
|
)
|
|
.unwrap()
|
|
.is_none());
|
|
|
|
insert(
|
|
"evt_pi_passive_shutdown",
|
|
AgentSource::Pi,
|
|
"pi-passive-shutdown",
|
|
AgentEventType::Done,
|
|
"session_shutdown",
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
false,
|
|
true,
|
|
);
|
|
assert!(database
|
|
.latest_connector_ordinary_receipt_for_contract(
|
|
AgentSource::Pi,
|
|
PI_EXTENSION_CONTRACT_VERSION,
|
|
)
|
|
.unwrap()
|
|
.is_none());
|
|
|
|
insert(
|
|
"evt_opencode_diagnostic_message",
|
|
AgentSource::Opencode,
|
|
"opencode-diagnostic-message",
|
|
AgentEventType::Start,
|
|
"message.user",
|
|
OPENCODE_CONTRACT_VERSION,
|
|
true,
|
|
true,
|
|
);
|
|
assert!(database
|
|
.latest_connector_ordinary_receipt_for_contract(
|
|
AgentSource::Opencode,
|
|
OPENCODE_CONTRACT_VERSION,
|
|
)
|
|
.unwrap()
|
|
.is_none());
|
|
|
|
insert(
|
|
"evt_opencode_ordinary_message",
|
|
AgentSource::Opencode,
|
|
"opencode-ordinary-message",
|
|
AgentEventType::Start,
|
|
"message.user",
|
|
OPENCODE_CONTRACT_VERSION,
|
|
false,
|
|
true,
|
|
);
|
|
let ordinary = database
|
|
.latest_connector_ordinary_receipt_for_contract(
|
|
AgentSource::Opencode,
|
|
OPENCODE_CONTRACT_VERSION,
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(ordinary.source_event, "message.user");
|
|
assert!(!ordinary.diagnostic);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_event_ingest_rejects_sensitive_unknown_payload_fields() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let error = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": "evt_sensitive_payload",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "curl --token APC_SECRET_SENTINEL_TITLE",
|
|
"detail": "Authorization: Bearer APC_SECRET_SENTINEL_DETAIL",
|
|
"project_path": "/tmp/apc-safe-project",
|
|
"session_id": "session=normal",
|
|
"payload": {
|
|
"source_event": "PostToolUse",
|
|
"tool_name": "shell",
|
|
"outcome": "completed",
|
|
"diagnostic": false,
|
|
"command": "curl --api-key APC_SECRET_SENTINEL_INLINE",
|
|
"token": "APC_SECRET_SENTINEL_TOKEN",
|
|
"nested": {
|
|
"access_token": "APC_SECRET_SENTINEL_ACCESS",
|
|
"safe": "visible"
|
|
},
|
|
"headers": {
|
|
"Authorization": "Bearer APC_SECRET_SENTINEL_AUTH",
|
|
"x-debug": "ok"
|
|
},
|
|
"array": [
|
|
{"cookie": "APC_SECRET_SENTINEL_COOKIE"},
|
|
"Bearer APC_SECRET_SENTINEL_ARRAY"
|
|
]
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
let error_text = error.to_string();
|
|
assert!(error_text.contains("payload field is not supported"));
|
|
assert!(!error_text.contains("APC_SECRET_SENTINEL_"));
|
|
|
|
let recent = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "events.recent".to_string(),
|
|
params: json!({ "limit": 1 }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(recent.as_array().unwrap().is_empty());
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(snapshot["events"].as_array().unwrap().is_empty());
|
|
assert!(snapshot["recent_events"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn agent_event_ingest_accepts_payload_json_alias() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let event = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": "evt_payload_json_alias",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "RAW_TITLE_ALIAS_SENTINEL",
|
|
"detail": "RAW_DETAIL_ALIAS_SENTINEL",
|
|
"payload_json": {
|
|
"source_event": "RAW_SOURCE_EVENT_ALIAS_SENTINEL",
|
|
"tool_name": "RAW_TOOL_NAME_ALIAS_SENTINEL",
|
|
"outcome": "RAW_OUTCOME_ALIAS_SENTINEL",
|
|
"diagnostic": true
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event["inserted"], true);
|
|
assert_eq!(event["event"]["title"], "执行工具");
|
|
assert!(event["event"]["detail"].is_null());
|
|
assert_eq!(
|
|
event["event"]["payload_json"]["source_event"],
|
|
"unclassified"
|
|
);
|
|
assert_eq!(event["event"]["payload_json"]["tool_name"], "other");
|
|
assert_eq!(event["event"]["payload_json"]["outcome"], "unknown");
|
|
assert_eq!(event["event"]["payload_json"]["diagnostic"], true);
|
|
|
|
let event_text = serde_json::to_string(&event).unwrap();
|
|
for sentinel in [
|
|
"RAW_TITLE_ALIAS_SENTINEL",
|
|
"RAW_DETAIL_ALIAS_SENTINEL",
|
|
"RAW_SOURCE_EVENT_ALIAS_SENTINEL",
|
|
"RAW_TOOL_NAME_ALIAS_SENTINEL",
|
|
"RAW_OUTCOME_ALIAS_SENTINEL",
|
|
] {
|
|
assert!(!event_text.contains(sentinel));
|
|
}
|
|
|
|
let recent = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "events.recent".to_string(),
|
|
params: json!({ "limit": 1 }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let recent_text = serde_json::to_string(&recent).unwrap();
|
|
for sentinel in [
|
|
"RAW_TITLE_ALIAS_SENTINEL",
|
|
"RAW_DETAIL_ALIAS_SENTINEL",
|
|
"RAW_SOURCE_EVENT_ALIAS_SENTINEL",
|
|
"RAW_TOOL_NAME_ALIAS_SENTINEL",
|
|
"RAW_OUTCOME_ALIAS_SENTINEL",
|
|
] {
|
|
assert!(!recent_text.contains(sentinel));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_settings_default_serializes_flat_stacked_sessions() {
|
|
let behavior = BehaviorSettings::default();
|
|
assert_eq!(behavior.interface_language, InterfaceLanguage::System);
|
|
assert!(!behavior.group_sessions_by_agent);
|
|
assert_eq!(behavior.session_group_display, SessionGroupDisplay::Stacked);
|
|
assert_eq!(
|
|
serde_json::to_value(&behavior).unwrap()["interface_language"],
|
|
"system"
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&behavior).unwrap()["session_group_display"],
|
|
"stacked"
|
|
);
|
|
assert_eq!(
|
|
behavior.bubble_font_scale,
|
|
petcore_types::BubbleFontScale::Standard
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(behavior).unwrap()["bubble_font_scale"],
|
|
"standard"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_settings_decode_legacy_sparse_json_with_defaults() {
|
|
let legacy = json!({
|
|
"enabled": true,
|
|
"mouse_passthrough": false,
|
|
"sources": {
|
|
"codex": false
|
|
},
|
|
"events": {
|
|
"tool": false
|
|
}
|
|
});
|
|
|
|
let decoded: BehaviorSettings = serde_json::from_value(legacy.clone()).unwrap();
|
|
assert_eq!(
|
|
decoded.appearance_theme,
|
|
petcore_types::AppearanceTheme::System
|
|
);
|
|
assert_eq!(decoded.interface_language, InterfaceLanguage::System);
|
|
assert!(!decoded.group_sessions_by_agent);
|
|
assert!(decoded.status_bubble);
|
|
assert!(decoded.click_menu);
|
|
assert!(decoded.mouse_passthrough);
|
|
assert!(!decoded.auto_hide);
|
|
assert_eq!(decoded.session_group_display, SessionGroupDisplay::Stacked);
|
|
assert_eq!(
|
|
decoded.bubble_font_scale,
|
|
petcore_types::BubbleFontScale::Standard
|
|
);
|
|
assert_eq!(decoded.sources.get(&AgentSource::Codex), Some(&false));
|
|
assert_eq!(decoded.sources.get(&AgentSource::ClaudeCode), Some(&true));
|
|
assert_eq!(decoded.sources.get(&AgentSource::Pi), Some(&true));
|
|
assert_eq!(decoded.sources.get(&AgentSource::Opencode), Some(&true));
|
|
assert_eq!(decoded.events.get(&AgentEventType::Tool), Some(&false));
|
|
assert_eq!(decoded.events.get(&AgentEventType::Start), Some(&true));
|
|
assert_eq!(decoded.events.get(&AgentEventType::Waiting), Some(&true));
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("settings.sqlite"));
|
|
database.init().unwrap();
|
|
database.set_setting("behavior", &legacy).unwrap();
|
|
let stored = database.behavior().unwrap();
|
|
assert_eq!(
|
|
stored.appearance_theme,
|
|
petcore_types::AppearanceTheme::System
|
|
);
|
|
assert_eq!(stored.interface_language, InterfaceLanguage::System);
|
|
assert!(!stored.group_sessions_by_agent);
|
|
assert!(stored.mouse_passthrough);
|
|
assert_eq!(stored.session_group_display, SessionGroupDisplay::Stacked);
|
|
assert_eq!(stored.sources.get(&AgentSource::Codex), Some(&false));
|
|
assert_eq!(stored.sources.get(&AgentSource::ClaudeCode), Some(&true));
|
|
assert_eq!(stored.events.get(&AgentEventType::Tool), Some(&false));
|
|
assert_eq!(stored.events.get(&AgentEventType::Done), Some(&true));
|
|
}
|
|
|
|
#[test]
|
|
fn connection_test_event_is_diagnostic_and_does_not_drive_overlay() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let test_event = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "connections.test".to_string(),
|
|
params: json!({ "source": "opencode" }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(test_event["ok"], true);
|
|
assert_eq!(test_event["inserted"], true);
|
|
assert_eq!(test_event["triggered"], false);
|
|
assert_eq!(test_event["state"], "idle");
|
|
assert_eq!(test_event["event"]["source"], "opencode");
|
|
assert_eq!(test_event["event"]["event_type"], "start");
|
|
assert_eq!(test_event["event"]["title"], "开始处理");
|
|
assert!(test_event["event"]["detail"].is_null());
|
|
assert_eq!(
|
|
test_event["event"]["payload_json"]["source_event"],
|
|
"connection.test"
|
|
);
|
|
assert_eq!(test_event["event"]["payload_json"]["diagnostic"], true);
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let overlay_events = snapshot["events"].as_array().unwrap();
|
|
assert!(overlay_events.is_empty());
|
|
assert!(snapshot["recent_events"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn startup_scrubs_legacy_connector_runtime_events_into_diagnostics() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
database
|
|
.insert_event(&AgentEvent {
|
|
id: "evt_legacy_connector_runtime".to_string(),
|
|
source: AgentSource::Opencode,
|
|
project_path: None,
|
|
session_id: Some("evt_opencode_runtime_legacy".to_string()),
|
|
event_type: AgentEventType::Start,
|
|
title: "开始处理".to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"schema_version": "apc.agent-event.v1",
|
|
"external_event_id": "evt_legacy_connector_runtime",
|
|
"source_event": "session.created",
|
|
"tool_name": null,
|
|
"outcome": "created",
|
|
"diagnostic": false,
|
|
"turn_id": null,
|
|
"session_active": false,
|
|
"message_role": null,
|
|
"message_content": null,
|
|
"interaction_kind": null,
|
|
"project_label": null
|
|
}),
|
|
created_at: "2026-07-14T00:00:00Z".to_string(),
|
|
})
|
|
.unwrap();
|
|
|
|
database.init().unwrap();
|
|
let event = database.recent_events(1).unwrap().remove(0);
|
|
assert_eq!(event.payload_json["diagnostic"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_reclassifies_legacy_pi_tool_errors_as_non_terminal_tool_events() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
database
|
|
.insert_event(&AgentEvent {
|
|
id: "evt_legacy_pi_tool_error".to_string(),
|
|
source: AgentSource::Pi,
|
|
project_path: None,
|
|
session_id: Some("pi-session-recovered".to_string()),
|
|
event_type: AgentEventType::Failed,
|
|
title: "失败".to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"schema_version": "apc.agent-event.v1",
|
|
"external_event_id": "evt_legacy_pi_tool_error",
|
|
"source_event": "tool_execution_end",
|
|
"tool_name": "filesystem",
|
|
"outcome": "tool_failure",
|
|
"diagnostic": false,
|
|
"turn_id": null,
|
|
"session_active": false,
|
|
"message_role": null,
|
|
"message_content": null,
|
|
"interaction_kind": null,
|
|
"project_label": null
|
|
}),
|
|
created_at: "2026-07-14T00:00:00Z".to_string(),
|
|
})
|
|
.unwrap();
|
|
|
|
database.init().unwrap();
|
|
let event = database.recent_events(1).unwrap().remove(0);
|
|
assert_eq!(event.event_type, AgentEventType::Tool);
|
|
assert_eq!(event.title, AgentEventType::Tool.zh_label());
|
|
assert_eq!(event.payload_json["outcome"], "tool_failure");
|
|
assert_eq!(event.payload_json["session_active"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_separates_overlay_events_from_recent_agent_history() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let initial = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "behavior.patch".to_string(),
|
|
params: json!({
|
|
"expected_revision": initial["behavior_revision"],
|
|
"changes": {
|
|
"enabled": false
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let real_event = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": "evt_recent_history_real",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(real_event["triggered"], false);
|
|
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": "evt_recent_history_diagnostic",
|
|
"source": "codex",
|
|
"event_type": "done",
|
|
"title": "连接自检",
|
|
"payload": {
|
|
"diagnostic": true
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
snapshot["events"].as_array().unwrap().is_empty(),
|
|
"disabled behavior should not expose overlay-driving events"
|
|
);
|
|
let recent_events = snapshot["recent_events"].as_array().unwrap();
|
|
assert!(recent_events
|
|
.iter()
|
|
.any(|event| event["id"] == projected_event_id("evt_recent_history_real")));
|
|
assert!(!recent_events
|
|
.iter()
|
|
.any(|event| event["id"] == projected_event_id("evt_recent_history_diagnostic")));
|
|
}
|
|
|
|
#[test]
|
|
fn state_wait_returns_snapshot_when_revision_changes() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let initial = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let initial_revision = initial["revision"].as_str().unwrap().to_string();
|
|
assert!(!initial_revision.is_empty());
|
|
|
|
let wait_state = state.clone();
|
|
let wait_revision = initial_revision.clone();
|
|
let (started_tx, started_rx) = mpsc::channel();
|
|
let waiter = std::thread::spawn(move || {
|
|
started_tx.send(()).unwrap();
|
|
handle_request(
|
|
&wait_state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.wait".to_string(),
|
|
params: json!({
|
|
"after_revision": wait_revision,
|
|
"timeout_ms": 2_000
|
|
}),
|
|
},
|
|
)
|
|
.unwrap()
|
|
});
|
|
started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
|
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_state_wait",
|
|
"source": "codex",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let waited = waiter.join().unwrap();
|
|
assert_eq!(waited["changed"], true);
|
|
assert_ne!(waited["revision"], initial_revision);
|
|
assert!(waited["events"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|event| event["id"] == projected_event_id("evt_state_wait")));
|
|
}
|
|
|
|
#[test]
|
|
fn generation_messages_wait_returns_when_message_file_changes() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
let job_id = "job_messages_wait";
|
|
let job_dir = state.paths.jobs_dir.join(job_id);
|
|
std::fs::create_dir_all(&job_dir).unwrap();
|
|
state
|
|
.database
|
|
.create_generation_job(
|
|
job_id,
|
|
&GenerationForm {
|
|
description: "legacy message migration".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: Vec::new(),
|
|
},
|
|
&job_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let initial = handle_request(
|
|
&state,
|
|
request(
|
|
"generation.messages.wait",
|
|
json!({
|
|
"job_id": job_id,
|
|
"after_revision": "",
|
|
"timeout_ms": 250
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let initial_revision = initial["revision"].as_str().unwrap().to_string();
|
|
assert!(initial["messages"].as_array().unwrap().is_empty());
|
|
|
|
let wait_state = state.clone();
|
|
let wait_revision = initial_revision.clone();
|
|
let (started_tx, started_rx) = mpsc::channel();
|
|
let waiter = std::thread::spawn(move || {
|
|
started_tx.send(()).unwrap();
|
|
handle_request(
|
|
&wait_state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages.wait".to_string(),
|
|
params: json!({
|
|
"job_id": job_id,
|
|
"after_revision": wait_revision,
|
|
"timeout_ms": 2_000
|
|
}),
|
|
},
|
|
)
|
|
.unwrap()
|
|
});
|
|
started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
|
|
|
std::fs::write(
|
|
job_dir.join("messages.jsonl"),
|
|
r#"{"role":"assistant","content":"生成消息已追加","progress":0.4,"created_at":"2026-07-08T00:00:00Z"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let waited = waiter.join().unwrap();
|
|
assert_eq!(waited["changed"], true);
|
|
assert_ne!(waited["revision"], initial_revision);
|
|
let messages = waited["messages"].as_array().unwrap();
|
|
assert_eq!(messages.len(), 1);
|
|
assert_eq!(messages[0]["content"], "生成消息已追加");
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_expires_old_overlay_events_without_deleting_history() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let expired_created_at = (OffsetDateTime::now_utc() - TimeDuration::minutes(1))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
let expired_done = handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_expired_done",
|
|
"source": "codex",
|
|
"event_type": "done",
|
|
"title": "完成",
|
|
"created_at": expired_created_at
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(expired_done["inserted"], true);
|
|
assert_eq!(expired_done["triggered"], false);
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(snapshot["events"].as_array().unwrap().len(), 0);
|
|
|
|
let recent = handle_request(&state, request("events.recent", json!({ "limit": 5 }))).unwrap();
|
|
assert_eq!(recent.as_array().unwrap().len(), 1);
|
|
assert_eq!(recent[0]["id"], "evt_expired_done");
|
|
|
|
let waiting = handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_current_waiting",
|
|
"source": "codex",
|
|
"event_type": "waiting",
|
|
"title": "等待确认"
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(waiting["triggered"], true);
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
snapshot["events"][0]["id"],
|
|
projected_event_id("evt_current_waiting")
|
|
);
|
|
|
|
let done = handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_current_done",
|
|
"source": "codex",
|
|
"event_type": "done",
|
|
"title": "完成"
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(done["triggered"], true);
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
snapshot["events"][0]["id"],
|
|
projected_event_id("evt_current_done")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_overlay_events_keep_only_latest_message_per_agent() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
let older_codex_time = (OffsetDateTime::now_utc() - TimeDuration::seconds(20))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
let claude_time = (OffsetDateTime::now_utc() - TimeDuration::seconds(10))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
let newer_codex_time = (OffsetDateTime::now_utc() - TimeDuration::seconds(5))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_codex_old_session",
|
|
"source": "codex",
|
|
"session_id": "codex-old",
|
|
"event_type": "tool",
|
|
"title": "旧 Codex 会话",
|
|
"created_at": older_codex_time
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_claude_current",
|
|
"source": "claude_code",
|
|
"session_id": "claude-current",
|
|
"event_type": "waiting",
|
|
"title": "Claude 当前会话",
|
|
"created_at": claude_time
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_codex_new_session",
|
|
"source": "codex",
|
|
"session_id": "codex-new",
|
|
"event_type": "done",
|
|
"title": "Codex 新会话",
|
|
"created_at": newer_codex_time
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let events = snapshot["events"].as_array().unwrap();
|
|
assert_eq!(events.len(), 2);
|
|
assert_eq!(events[0]["id"], projected_event_id("evt_codex_new_session"));
|
|
assert_eq!(events[1]["id"], projected_event_id("evt_claude_current"));
|
|
assert!(events
|
|
.iter()
|
|
.all(|event| event["id"] != projected_event_id("evt_codex_old_session")));
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_does_not_restore_old_work_event_after_terminal_expires() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
let active_work_time = (OffsetDateTime::now_utc() - TimeDuration::seconds(60))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
let expired_terminal_time = (OffsetDateTime::now_utc() - TimeDuration::seconds(9))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_session_waiting_before_done",
|
|
"source": "codex",
|
|
"session_id": "session-terminal-order",
|
|
"event_type": "waiting",
|
|
"title": "等待确认",
|
|
"created_at": active_work_time
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_session_done_expired",
|
|
"source": "codex",
|
|
"session_id": "session-terminal-order",
|
|
"event_type": "done",
|
|
"title": "完成",
|
|
"created_at": expired_terminal_time
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let events = snapshot["events"].as_array().unwrap();
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.all(|event| { event["id"] != projected_event_id("evt_session_waiting_before_done") }),
|
|
"expired terminal event should close the session instead of restoring old work: {events:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_overlay_events_survive_diagnostic_noise() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "evt_real_survives_diagnostics",
|
|
"source": "claude_code",
|
|
"session_id": "session-diagnostic-noise",
|
|
"event_type": "tool",
|
|
"title": "执行工具"
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
for index in 0..24 {
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": format!("evt_diagnostic_noise_{index}"),
|
|
"source": "codex",
|
|
"event_type": "done",
|
|
"title": "连接自检",
|
|
"payload": {
|
|
"diagnostic": true
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert!(snapshot["events"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|event| event["id"] == projected_event_id("evt_real_survives_diagnostics")));
|
|
assert!(snapshot["recent_events"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|event| {
|
|
(0..64).all(|index| {
|
|
event["id"] != projected_event_id(&format!("evt_diagnostic_noise_{index}"))
|
|
})
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn future_agent_event_created_at_is_clamped() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let future = "2099-01-01T00:00:00Z";
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "agent.ingest".to_string(),
|
|
params: json!({
|
|
"id": "evt_future_created_at",
|
|
"source": "opencode",
|
|
"event_type": "tool",
|
|
"title": "执行工具",
|
|
"created_at": future
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let recent = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "events.recent".to_string(),
|
|
params: json!({ "limit": 1 }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(recent[0]["id"], "evt_future_created_at");
|
|
assert_ne!(recent[0]["created_at"], future);
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_placement_persists_through_snapshot() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths.clone());
|
|
state.ensure_ready().unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let initial = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(initial["overlay_placement"]["display_width_pt"], 112.0);
|
|
assert!(initial["overlay_placement"].get("scale").is_none());
|
|
assert!(initial.get("overlay_placement_intent").is_none());
|
|
let initial_placement_revision = initial["overlay_placement_revision"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse::<u64>()
|
|
.unwrap();
|
|
let initial_revision = initial["revision"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse::<u64>()
|
|
.unwrap();
|
|
let initial_get = handle_request(&state, request("overlay.placement.get", json!({}))).unwrap();
|
|
assert_eq!(
|
|
initial_get["overlay_placement"],
|
|
initial["overlay_placement"]
|
|
);
|
|
assert_eq!(
|
|
initial_get["overlay_placement_revision"],
|
|
initial["overlay_placement_revision"]
|
|
);
|
|
assert!(initial_get["overlay_placement_intent"].is_null());
|
|
|
|
let update = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.update",
|
|
json!({
|
|
"x": 321.0,
|
|
"y": 654.0,
|
|
"display_width_pt": 180.0,
|
|
"display_id": "display-test",
|
|
"expected_revision": initial_placement_revision.to_string()
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let update_revision = update["revision"].as_str().unwrap().parse::<u64>().unwrap();
|
|
assert!(update_revision > initial_revision);
|
|
assert_eq!(
|
|
update["overlay_placement_revision"],
|
|
(initial_placement_revision + 1).to_string()
|
|
);
|
|
|
|
let placement_updated_at_before_noop: String =
|
|
rusqlite::Connection::open(state.database.path())
|
|
.unwrap()
|
|
.query_row(
|
|
"SELECT updated_at FROM settings WHERE key = 'overlay_placement'",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
let equivalent_noop = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.update",
|
|
json!({
|
|
"x": 321.0001,
|
|
"y": 654.0001,
|
|
"display_width_pt": 180.0,
|
|
"display_id": "display-test",
|
|
"expected_revision": update["overlay_placement_revision"]
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(equivalent_noop["ok"], true);
|
|
assert_eq!(equivalent_noop["changed"], false);
|
|
assert_eq!(equivalent_noop["revision"], update["revision"]);
|
|
assert_eq!(
|
|
equivalent_noop["overlay_placement_revision"],
|
|
update["overlay_placement_revision"]
|
|
);
|
|
let placement_updated_at_after_noop: String = rusqlite::Connection::open(state.database.path())
|
|
.unwrap()
|
|
.query_row(
|
|
"SELECT updated_at FROM settings WHERE key = 'overlay_placement'",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
placement_updated_at_after_noop,
|
|
placement_updated_at_before_noop
|
|
);
|
|
let stale_equivalent = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.update",
|
|
json!({
|
|
"x": 321.0,
|
|
"y": 654.0,
|
|
"display_width_pt": 180.0,
|
|
"display_id": "display-test",
|
|
"expected_revision": initial_placement_revision.to_string()
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(stale_equivalent["ok"], false);
|
|
assert_eq!(stale_equivalent["conflict"], true);
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(snapshot["overlay_placement"]["x"], 321.0);
|
|
assert_eq!(snapshot["overlay_placement"]["y"], 654.0);
|
|
assert_eq!(snapshot["overlay_placement"]["display_width_pt"], 180.0);
|
|
assert_eq!(snapshot["overlay_placement"]["display_id"], "display-test");
|
|
assert_eq!(snapshot["revision"], update["revision"]);
|
|
assert_eq!(
|
|
snapshot["overlay_placement_revision"],
|
|
update["overlay_placement_revision"]
|
|
);
|
|
assert!(snapshot.get("overlay_placement_intent").is_none());
|
|
|
|
let reposition = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.reposition",
|
|
json!({
|
|
"x": 480.0,
|
|
"y": 360.0,
|
|
"display_width_pt": 112.0,
|
|
"display_id": "external-display"
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
reposition["overlay_placement_intent"],
|
|
"external_reposition"
|
|
);
|
|
let reposition_placement_revision = reposition["overlay_placement_revision"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse::<u64>()
|
|
.unwrap();
|
|
|
|
let reposition_snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
reposition_snapshot["overlay_placement_intent"],
|
|
"external_reposition"
|
|
);
|
|
let waited = handle_request(
|
|
&state,
|
|
request(
|
|
"state.wait",
|
|
json!({
|
|
"after_revision": update["revision"],
|
|
"timeout_ms": 250
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(waited["changed"], true);
|
|
assert_eq!(waited["revision"], reposition["revision"]);
|
|
assert_eq!(waited["overlay_placement_intent"], "external_reposition");
|
|
|
|
state
|
|
.database
|
|
.set_setting("overlay_intent_test_revision", &true)
|
|
.unwrap();
|
|
let later_snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
later_snapshot["overlay_placement_intent"],
|
|
"external_reposition"
|
|
);
|
|
assert_eq!(
|
|
later_snapshot["overlay_placement_revision"],
|
|
reposition["overlay_placement_revision"]
|
|
);
|
|
assert_ne!(later_snapshot["revision"], reposition["revision"]);
|
|
|
|
drop(state);
|
|
let state = CoreState::new(paths.clone());
|
|
state.ensure_ready().unwrap();
|
|
let reopened_reposition = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
reopened_reposition["overlay_placement_intent"],
|
|
"external_reposition"
|
|
);
|
|
assert_eq!(reopened_reposition["overlay_placement"]["x"], 480.0);
|
|
assert_eq!(
|
|
reopened_reposition["overlay_placement_revision"],
|
|
reposition["overlay_placement_revision"]
|
|
);
|
|
|
|
let second_reposition_placement = json!({
|
|
"x": 720.0,
|
|
"y": 410.0,
|
|
"display_width_pt": 112.0,
|
|
"display_id": "second-external-display"
|
|
});
|
|
let second_reposition = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.reposition",
|
|
second_reposition_placement.clone(),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
second_reposition["overlay_placement_revision"],
|
|
(reposition_placement_revision + 1).to_string()
|
|
);
|
|
|
|
let stale_ack = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.update",
|
|
json!({
|
|
"x": 480.0,
|
|
"y": 360.0,
|
|
"display_width_pt": 112.0,
|
|
"display_id": "external-display",
|
|
"expected_revision": reposition_placement_revision.to_string()
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(stale_ack["ok"], false);
|
|
assert_eq!(stale_ack["conflict"], true);
|
|
assert_eq!(
|
|
stale_ack["overlay_placement"],
|
|
second_reposition["overlay_placement"]
|
|
);
|
|
assert_eq!(
|
|
stale_ack["overlay_placement_revision"],
|
|
second_reposition["overlay_placement_revision"]
|
|
);
|
|
assert_eq!(stale_ack["overlay_placement_intent"], "external_reposition");
|
|
|
|
let after_stale_ack = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
after_stale_ack["overlay_placement"],
|
|
second_reposition["overlay_placement"]
|
|
);
|
|
assert_eq!(
|
|
after_stale_ack["overlay_placement_revision"],
|
|
second_reposition["overlay_placement_revision"]
|
|
);
|
|
assert_eq!(
|
|
after_stale_ack["overlay_placement_intent"],
|
|
"external_reposition"
|
|
);
|
|
let get_after_stale_ack =
|
|
handle_request(&state, request("overlay.placement.get", json!({}))).unwrap();
|
|
assert_eq!(
|
|
get_after_stale_ack["overlay_placement"],
|
|
second_reposition["overlay_placement"]
|
|
);
|
|
assert_eq!(
|
|
get_after_stale_ack["overlay_placement_revision"],
|
|
second_reposition["overlay_placement_revision"]
|
|
);
|
|
assert_eq!(
|
|
get_after_stale_ack["overlay_placement_intent"],
|
|
"external_reposition"
|
|
);
|
|
|
|
let mut correct_ack_params = second_reposition_placement;
|
|
correct_ack_params.as_object_mut().unwrap().insert(
|
|
"expected_revision".to_string(),
|
|
second_reposition["overlay_placement_revision"].clone(),
|
|
);
|
|
let correct_ack = handle_request(
|
|
&state,
|
|
request("overlay.placement.update", correct_ack_params),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(correct_ack["ok"], true);
|
|
assert!(correct_ack["overlay_placement_intent"].is_null());
|
|
|
|
let reset = handle_request(&state, request("overlay.placement.reset", json!({}))).unwrap();
|
|
assert_eq!(reset["overlay_placement_intent"], "reset");
|
|
let reset_snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(
|
|
reset_snapshot["overlay_placement"],
|
|
json!(OverlayPlacement::default())
|
|
);
|
|
assert_eq!(reset_snapshot["overlay_placement_intent"], "reset");
|
|
|
|
drop(state);
|
|
let state = CoreState::new(paths.clone());
|
|
state.ensure_ready().unwrap();
|
|
let reopened_reset = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(reopened_reset["overlay_placement_intent"], "reset");
|
|
|
|
let ordinary_update = handle_request(
|
|
&state,
|
|
request(
|
|
"overlay.placement.update",
|
|
json!({
|
|
"x": OverlayPlacement::default().x,
|
|
"y": OverlayPlacement::default().y,
|
|
"display_width_pt": OverlayPlacement::default().display_width_pt,
|
|
"display_id": OverlayPlacement::default().display_id,
|
|
"expected_revision": reset["overlay_placement_revision"]
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert!(ordinary_update["overlay_placement_intent"].is_null());
|
|
let ordinary_snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert!(ordinary_snapshot.get("overlay_placement_intent").is_none());
|
|
|
|
drop(state);
|
|
let reopened_state = CoreState::new(paths);
|
|
reopened_state.ensure_ready().unwrap();
|
|
let reopened_acknowledged =
|
|
handle_request(&reopened_state, request("state.snapshot", json!({}))).unwrap();
|
|
assert!(reopened_acknowledged
|
|
.get("overlay_placement_intent")
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn database_recovers_corrupt_sqlite_without_touching_petpacks() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
paths.ensure().unwrap();
|
|
|
|
let petpack_path = paths.pets_dir.join("keep.petpack");
|
|
std::fs::write(&petpack_path, b"petpack-bytes").unwrap();
|
|
std::fs::write(&paths.db_path, b"this is not sqlite").unwrap();
|
|
let wal_path = std::path::PathBuf::from(format!("{}-wal", paths.db_path.display()));
|
|
let shm_path = std::path::PathBuf::from(format!("{}-shm", paths.db_path.display()));
|
|
std::fs::write(&wal_path, b"stale wal").unwrap();
|
|
std::fs::write(&shm_path, b"stale shm").unwrap();
|
|
|
|
let database = Database::new(paths.db_path.clone());
|
|
database.init().unwrap();
|
|
|
|
assert!(paths.db_path.is_file());
|
|
assert_eq!(std::fs::read(&petpack_path).unwrap(), b"petpack-bytes");
|
|
assert!(database.behavior().unwrap().enabled);
|
|
|
|
let backup = std::fs::read_dir(&paths.home)
|
|
.unwrap()
|
|
.map(|entry| entry.unwrap().path())
|
|
.find(|path| {
|
|
path.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.starts_with("agent-pet.sqlite.corrupt-"))
|
|
&& !path.to_string_lossy().ends_with("-wal")
|
|
&& !path.to_string_lossy().ends_with("-shm")
|
|
})
|
|
.expect("corrupt sqlite backup should exist");
|
|
assert_eq!(std::fs::read(&backup).unwrap(), b"this is not sqlite");
|
|
assert!(std::path::PathBuf::from(format!("{}-wal", backup.display())).is_file());
|
|
assert!(std::path::PathBuf::from(format!("{}-shm", backup.display())).is_file());
|
|
assert!(!wal_path.exists());
|
|
assert!(!shm_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn database_migrates_v2_pet_generation_source_columns() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let db_path = temp.path().join("legacy.sqlite");
|
|
{
|
|
let connection = rusqlite::Connection::open(&db_path).unwrap();
|
|
connection
|
|
.execute_batch(
|
|
r#"
|
|
CREATE TABLE pets (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
style TEXT NOT NULL,
|
|
quality TEXT NOT NULL,
|
|
render_width INTEGER NOT NULL,
|
|
render_height INTEGER NOT NULL,
|
|
states_json TEXT NOT NULL,
|
|
petpack_path TEXT NOT NULL,
|
|
cover_path TEXT NOT NULL,
|
|
active INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let states_json = serde_json::to_string(&petcore_types::default_pet_states()).unwrap();
|
|
connection
|
|
.execute(
|
|
r#"
|
|
INSERT INTO pets
|
|
(id, name, style, quality, render_width, render_height, states_json,
|
|
petpack_path, cover_path, active, created_at)
|
|
VALUES
|
|
('pet_legacy', 'Legacy Pet', '半写实', 'standard', 384, 416, ?1,
|
|
'/tmp/legacy.petpack', '/tmp/legacy-cover.png', 1,
|
|
'2026-07-09T00:00:00Z')
|
|
"#,
|
|
rusqlite::params![states_json],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let database = Database::new(&db_path);
|
|
database.init().unwrap();
|
|
let mut pet = database.get_pet("pet_legacy").unwrap().unwrap();
|
|
assert_eq!(pet.origin, petcore_types::PetOrigin::ExternalImport);
|
|
assert_eq!(pet.generator, None);
|
|
assert_eq!(pet.provenance, None);
|
|
assert_eq!(pet.states, petcore_types::default_pet_states());
|
|
let schema_version: u32 = rusqlite::Connection::open(&db_path)
|
|
.unwrap()
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.unwrap();
|
|
assert_eq!(schema_version, 6);
|
|
|
|
pet.generator = Some("codex-app-server-brief-petpack-v3".to_string());
|
|
pet.provenance = Some("codex_app_server_brief".to_string());
|
|
database.upsert_pet(&pet).unwrap();
|
|
|
|
let stored = database.get_pet("pet_legacy").unwrap().unwrap();
|
|
assert_eq!(stored.origin, petcore_types::PetOrigin::ExternalImport);
|
|
assert_eq!(
|
|
stored.generator.as_deref(),
|
|
Some("codex-app-server-brief-petpack-v3")
|
|
);
|
|
assert_eq!(stored.provenance.as_deref(), Some("codex_app_server_brief"));
|
|
}
|
|
|
|
#[test]
|
|
fn renderer_budget_uses_actual_authored_frame_count_without_ring_cache() {
|
|
let high = petcore::metrics::renderer_budget(QualityLevel::High, 40);
|
|
let standard = petcore::metrics::renderer_budget(QualityLevel::Standard, 40);
|
|
assert_eq!(standard.frame_count, 40);
|
|
assert_eq!(standard.runtime_cache_frame_limit, 40);
|
|
assert!(!standard.uses_ring_cache);
|
|
assert_eq!(
|
|
standard.estimated_runtime_cache_mb,
|
|
standard.decoded_state_mb
|
|
);
|
|
|
|
let low = petcore::metrics::renderer_budget(QualityLevel::Low, 2);
|
|
assert_eq!(low.frame_count, 2);
|
|
assert_eq!(low.runtime_cache_frame_limit, 2);
|
|
assert!(!low.uses_ring_cache);
|
|
assert!(low.estimated_runtime_cache_mb < standard.estimated_runtime_cache_mb);
|
|
assert!(standard.estimated_runtime_cache_mb < high.estimated_runtime_cache_mb);
|
|
assert_eq!(high.runtime_cache_frame_limit, 40);
|
|
assert!(!high.uses_ring_cache);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_active_pet_removes_assets_and_reactivates_remaining_pet() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let first_source = temp.path().join("first");
|
|
let second_source = temp.path().join("second");
|
|
write_sample_petpack_dir(&first_source, QualityLevel::Standard, "First Pet", "半写实").unwrap();
|
|
write_sample_petpack_dir(
|
|
&second_source,
|
|
QualityLevel::Standard,
|
|
"Second Pet",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let first = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": first_source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let second = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": second_source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let first_id = first["id"].as_str().unwrap();
|
|
let first_petpack = std::path::PathBuf::from(first["petpack_path"].as_str().unwrap());
|
|
let first_cover = std::path::PathBuf::from(first["cover_path"].as_str().unwrap());
|
|
let first_frames = first_petpack
|
|
.parent()
|
|
.unwrap()
|
|
.join(format!("{first_id}-frames"));
|
|
assert!(first_petpack.is_file());
|
|
assert!(first_cover.is_file());
|
|
assert!(first_frames.join("idle/0000.png").is_file());
|
|
assert!(first_frames.join("tool/0000.png").is_file());
|
|
|
|
handle_request(&state, request("pet.activate", json!({ "id": first_id }))).unwrap();
|
|
let deleted = handle_request(&state, request("pet.delete", json!({ "id": first_id }))).unwrap();
|
|
assert_eq!(deleted["deleted_assets"], true);
|
|
assert!(!first_petpack.exists());
|
|
assert!(!first_cover.exists());
|
|
assert!(!first_frames.exists());
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
assert_eq!(pets[0]["id"], second["id"]);
|
|
assert_eq!(pets[0]["active"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn pet_delete_keeps_database_row_when_asset_staging_fails() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let source = temp.path().join("broken-delete");
|
|
write_sample_petpack_dir(&source, QualityLevel::Standard, "Broken Delete", "半写实").unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let imported = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let pet_id = imported["id"].as_str().unwrap();
|
|
let cover_path = std::path::PathBuf::from(imported["cover_path"].as_str().unwrap());
|
|
std::fs::remove_file(&cover_path).unwrap();
|
|
std::fs::create_dir(&cover_path).unwrap();
|
|
|
|
let error = handle_request(&state, request("pet.delete", json!({ "id": pet_id })))
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(error.contains("not a file"));
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
assert_eq!(pets[0]["id"], pet_id);
|
|
assert_eq!(pets[0]["active"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn activating_or_deleting_unknown_pet_does_not_clear_active_pet() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let source = temp.path().join("known");
|
|
write_sample_petpack_dir(&source, QualityLevel::Standard, "Known Pet", "半写实").unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let imported = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let pet_id = imported["id"].as_str().unwrap();
|
|
assert_eq!(imported["active"], true);
|
|
|
|
let activate_error = handle_request(
|
|
&state,
|
|
request("pet.activate", json!({ "id": "pet_missing" })),
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(activate_error.contains("pet not found"));
|
|
|
|
let delete_error = handle_request(
|
|
&state,
|
|
request("pet.delete", json!({ "id": "pet_missing" })),
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(delete_error.contains("pet not found"));
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
assert_eq!(pets[0]["id"], pet_id);
|
|
assert_eq!(pets[0]["active"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn reimporting_petpack_preserves_active_state_and_owned_package() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let source = temp.path().join("reimport-source");
|
|
write_sample_petpack_dir(&source, QualityLevel::Standard, "Reimport Pet", "半写实").unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let imported = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let pet_id = imported["id"].as_str().unwrap();
|
|
let owned_petpack = imported["petpack_path"].as_str().unwrap();
|
|
handle_request(&state, request("pet.activate", json!({ "id": pet_id }))).unwrap();
|
|
|
|
let reimported_from_source = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(reimported_from_source["id"], pet_id);
|
|
assert_eq!(reimported_from_source["active"], true);
|
|
|
|
let reimported_from_owned_package = handle_request(
|
|
&state,
|
|
request("petpack.import", json!({ "path": owned_petpack })),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(reimported_from_owned_package["id"], pet_id);
|
|
assert_eq!(reimported_from_owned_package["active"], true);
|
|
assert!(std::path::Path::new(owned_petpack).is_file());
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
assert_eq!(pets[0]["id"], pet_id);
|
|
assert_eq!(pets[0]["active"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_repairs_legacy_relative_cover_path_from_petpack() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let source = temp.path().join("legacy-cover");
|
|
write_sample_petpack_dir(
|
|
&source,
|
|
QualityLevel::Standard,
|
|
"Legacy Cover Pet",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
let imported = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({ "path": source.display().to_string() }),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let mut legacy_pet: PetSummary = serde_json::from_value(imported).unwrap();
|
|
let original_cover = std::path::PathBuf::from(&legacy_pet.cover_path);
|
|
assert!(original_cover.is_file());
|
|
std::fs::remove_file(&original_cover).unwrap();
|
|
legacy_pet.cover_path = "assets/preview/cover.png".to_string();
|
|
state.database.upsert_pet(&legacy_pet).unwrap();
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
let repaired_cover = pets[0]["cover_path"].as_str().unwrap();
|
|
assert_ne!(repaired_cover, "assets/preview/cover.png");
|
|
assert!(std::path::Path::new(repaired_cover).is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_exposes_cached_pet_asset_repair_failure() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
let source = temp.path().join("snapshot-warning-source");
|
|
write_sample_petpack_dir(
|
|
&source,
|
|
QualityLevel::Standard,
|
|
"Snapshot Warning",
|
|
"半写实",
|
|
)
|
|
.unwrap();
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("snapshot-warning")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
let imported = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({"path": source.display().to_string()}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let petpack = std::path::PathBuf::from(imported["petpack_path"].as_str().unwrap());
|
|
let frames = petpack
|
|
.parent()
|
|
.unwrap()
|
|
.join(format!("{}-frames", imported["id"].as_str().unwrap()));
|
|
std::fs::remove_dir_all(frames).unwrap();
|
|
std::fs::write(&petpack, b"corrupt package").unwrap();
|
|
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let warnings = snapshot["pet_asset_warnings"].as_array().unwrap();
|
|
assert_eq!(warnings.len(), 1);
|
|
assert_eq!(warnings[0]["pet_id"], imported["id"]);
|
|
assert_eq!(warnings[0]["code"], "pet_assets_invalid");
|
|
assert!(warnings[0]["message"]
|
|
.as_str()
|
|
.is_some_and(|value| !value.is_empty()));
|
|
assert_eq!(snapshot["pets"].as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_asset_repair_rpc_clears_a_cached_warning() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
let source = temp.path().join("rpc-explicit-repair-source");
|
|
write_sample_petpack_dir(&source, QualityLevel::Standard, "RPC Repair", "半写实").unwrap();
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("rpc-explicit-repair")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
let imported = handle_request(
|
|
&state,
|
|
request(
|
|
"petpack.import",
|
|
json!({"path": source.display().to_string()}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
let pet: PetSummary = serde_json::from_value(imported.clone()).unwrap();
|
|
handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let validation = state
|
|
.database
|
|
.pet_asset_validation(&pet.id)
|
|
.unwrap()
|
|
.unwrap();
|
|
state
|
|
.database
|
|
.set_pet_asset_validation(
|
|
&pet.id,
|
|
&validation.fingerprint,
|
|
false,
|
|
Some("cached warning"),
|
|
)
|
|
.unwrap();
|
|
|
|
let warned = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert_eq!(warned["pet_asset_warnings"].as_array().unwrap().len(), 1);
|
|
let outcome =
|
|
handle_request(&state, request("pet.assets.repair", json!({"id": pet.id}))).unwrap();
|
|
assert_eq!(outcome["pet"]["id"], imported["id"]);
|
|
assert!(outcome["warning"].is_null());
|
|
let repaired = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
assert!(repaired["pet_asset_warnings"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn daemon_does_not_remove_active_socket() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
paths.ensure().unwrap();
|
|
let _listener = UnixListener::bind(&paths.socket_path).unwrap();
|
|
|
|
let error = daemon::serve(paths, None).unwrap_err().to_string();
|
|
assert!(error.contains("already active"));
|
|
}
|
|
|
|
#[test]
|
|
fn http_agent_events_require_capability_token() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let ready_file = temp.path().join("daemon-ready");
|
|
let serve_paths = paths.clone();
|
|
std::thread::spawn(move || daemon::serve(serve_paths, Some(&ready_file)).unwrap());
|
|
wait_for_file(&paths.http_port_path);
|
|
wait_for_file(&paths.token_path);
|
|
|
|
let token = std::fs::read_to_string(&paths.token_path)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
let token_permissions = std::fs::metadata(&paths.token_path)
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
assert_eq!(token_permissions, 0o600);
|
|
let port = std::fs::read_to_string(&paths.http_port_path)
|
|
.unwrap()
|
|
.trim()
|
|
.parse::<u16>()
|
|
.unwrap();
|
|
let event = json!({
|
|
"id": "evt_http_token",
|
|
"source": "codex",
|
|
"project_path": "/tmp/http-project",
|
|
"session_id": "sess_http",
|
|
"event_type": "tool",
|
|
"title": "执行工具",
|
|
"detail": "Bash"
|
|
});
|
|
|
|
let (unauthorized_status, unauthorized_body) = http_post_agent_event(port, None, &event);
|
|
assert_eq!(unauthorized_status, 401);
|
|
assert_eq!(unauthorized_body["error"], "missing capability token");
|
|
|
|
let (authorized_status, authorized_body) = http_post_agent_event(port, Some(&token), &event);
|
|
assert_eq!(authorized_status, 200);
|
|
assert_eq!(authorized_body["inserted"], true);
|
|
assert_eq!(authorized_body["triggered"], true);
|
|
|
|
let recent = daemon::request(&paths, "events.recent", json!({ "limit": 1 })).unwrap();
|
|
assert_eq!(recent[0]["id"], "evt_http_token");
|
|
assert_eq!(recent[0]["source"], "codex");
|
|
assert_eq!(recent[0]["event_type"], "tool");
|
|
let snapshot = daemon::request(&paths, "state.snapshot", json!({})).unwrap();
|
|
assert_eq!(
|
|
snapshot["events"][0]["id"],
|
|
projected_event_id("evt_http_token")
|
|
);
|
|
|
|
std::fs::remove_file(&paths.token_path).unwrap();
|
|
let (stale_status, stale_body) = http_post_agent_event(port, Some(&token), &event);
|
|
assert_eq!(stale_status, 401);
|
|
assert_eq!(stale_body["error"], "missing capability token");
|
|
wait_for_file(&paths.token_path);
|
|
let healed_token = std::fs::read_to_string(&paths.token_path)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
assert!(!healed_token.is_empty());
|
|
assert_ne!(healed_token, token);
|
|
let healed_permissions = std::fs::metadata(&paths.token_path)
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
assert_eq!(healed_permissions, 0o600);
|
|
let mut healed_event = event.clone();
|
|
healed_event["id"] = json!("evt_http_token_healed");
|
|
let (healed_status, healed_body) =
|
|
http_post_agent_event(port, Some(&healed_token), &healed_event);
|
|
assert_eq!(healed_status, 200);
|
|
assert_eq!(healed_body["inserted"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_token_rebuilds_empty_and_expired_files() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
paths.ensure().unwrap();
|
|
|
|
std::fs::write(&paths.token_path, "").unwrap();
|
|
std::fs::set_permissions(&paths.token_path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
|
let rebuilt = daemon::write_capability_token(&paths).unwrap();
|
|
assert!(rebuilt.starts_with("cap_"));
|
|
assert_eq!(
|
|
std::fs::metadata(&paths.token_path)
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o600
|
|
);
|
|
|
|
let fresh = daemon::write_capability_token(&paths).unwrap();
|
|
assert_eq!(fresh, rebuilt);
|
|
|
|
let old_time = SystemTime::now() - Duration::from_secs(48 * 60 * 60);
|
|
let token_file = std::fs::File::options()
|
|
.read(true)
|
|
.write(true)
|
|
.open(&paths.token_path)
|
|
.unwrap();
|
|
token_file
|
|
.set_times(
|
|
std::fs::FileTimes::new()
|
|
.set_accessed(old_time)
|
|
.set_modified(old_time),
|
|
)
|
|
.unwrap();
|
|
let rotated = daemon::write_capability_token(&paths).unwrap();
|
|
assert_ne!(rotated, rebuilt);
|
|
assert!(rotated.starts_with("cap_"));
|
|
}
|
|
|
|
#[test]
|
|
fn launch_agent_plist_can_be_installed_and_uninstalled_without_loading() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let launch_agents_dir = temp.path().join("LaunchAgents");
|
|
let program = temp.path().join("bin/petcore");
|
|
let home = temp.path().join("home");
|
|
let user_home = temp.path().join("user-home");
|
|
let pi_config = temp.path().join("pi-agent");
|
|
let claude_cli = temp.path().join("bin/claude");
|
|
let _launch_dir = EnvVarGuard::set("APC_LAUNCH_AGENT_DIR", &launch_agents_dir);
|
|
let _skip_launchctl = EnvVarGuard::set("APC_SKIP_LAUNCHCTL", "1");
|
|
let _user_home = EnvVarGuard::set("HOME", &user_home);
|
|
let _pi_config = EnvVarGuard::set("PI_CODING_AGENT_DIR", &pi_config);
|
|
let _claude_cli = EnvVarGuard::set("APC_CLAUDE_CLI_PATH", &claude_cli);
|
|
let _relative_pi_cli = EnvVarGuard::set("APC_PI_CLI_PATH", "relative/pi");
|
|
let _relative_opencode = EnvVarGuard::set("OPENCODE_CONFIG_DIR", "relative/opencode");
|
|
|
|
let config = LaunchAgentConfig::new(program.clone(), home.clone());
|
|
let plist = config.plist_xml();
|
|
assert!(plist.contains("<key>Label</key>"));
|
|
assert!(plist.contains("<string>dev.agentpet.petcore</string>"));
|
|
assert!(plist.contains("<key>RunAtLoad</key>"));
|
|
assert!(plist.contains("<key>KeepAlive</key>"));
|
|
assert!(plist.contains(&format!("<string>{}</string>", program.display())));
|
|
assert!(plist.contains(&format!("<string>{}</string>", home.display())));
|
|
assert!(plist.contains("<key>HOME</key>"));
|
|
assert!(plist.contains(&format!("<string>{}</string>", user_home.display())));
|
|
assert!(plist.contains("<key>PI_CODING_AGENT_DIR</key>"));
|
|
assert!(plist.contains(&format!("<string>{}</string>", pi_config.display())));
|
|
assert!(plist.contains("<key>APC_CLAUDE_CLI_PATH</key>"));
|
|
assert!(plist.contains(&format!("<string>{}</string>", claude_cli.display())));
|
|
assert!(plist.contains(&user_home.join(".opencode/bin").display().to_string()));
|
|
assert!(plist.contains(&user_home.join(".volta/bin").display().to_string()));
|
|
assert!(!plist.contains("relative/opencode"));
|
|
assert!(!plist.contains("relative/pi"));
|
|
|
|
let installed = launch_agent::install(&config, false).unwrap();
|
|
assert_eq!(installed.label, launch_agent::DEFAULT_LABEL);
|
|
assert!(installed.installed);
|
|
assert_eq!(installed.loaded, None);
|
|
let plist_path = launch_agents_dir.join("dev.agentpet.petcore.plist");
|
|
assert!(plist_path.is_file());
|
|
let written = std::fs::read_to_string(&plist_path).unwrap();
|
|
assert_eq!(written, plist);
|
|
|
|
let status = launch_agent::status(false);
|
|
assert!(status.installed);
|
|
assert_eq!(status.loaded, None);
|
|
|
|
let uninstalled = launch_agent::uninstall(launch_agent::DEFAULT_LABEL, false).unwrap();
|
|
assert!(!uninstalled.installed);
|
|
assert!(!plist_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn generation_pauses_recoverably_without_app_server_when_local_fallback_is_not_enabled() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "");
|
|
let _disable_app_server_auto = EnvVarGuard::set("APC_DISABLE_CODEX_APP_SERVER_AUTO", "1");
|
|
let _disable_local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "0");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "必须由真实 Codex App Server 生成。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let messages = loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let failed = messages.as_array().unwrap().iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("请在 Agent 连接中修复 Codex App Server 后重试")
|
|
});
|
|
if failed {
|
|
break messages;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not fail when App Server was unavailable"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
};
|
|
|
|
assert!(messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Codex App Server brief turn 未完成")
|
|
}));
|
|
let job = state.database.generation_job(job_id).unwrap().unwrap();
|
|
assert_eq!(job.status, GenerationJobStatus::Failed);
|
|
assert!(job.recoverable);
|
|
assert!(job.ended_at.is_none());
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(snapshot["pets"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_pauses_recoverably_when_skill_petpack_source_is_invalid_without_local_fallback() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let wait_file = temp.path().join("allow-invalid-source-complete");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_invalid_petpack_source");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
|
|
let _disable_local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "0");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "Skill 写出的 petpack-source 无效时必须失败。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
let invalid_source = state.paths.jobs_dir.join(job_id).join("petpack-source");
|
|
std::fs::create_dir_all(&invalid_source).unwrap();
|
|
std::fs::write(invalid_source.join("manifest.json"), "{ invalid json").unwrap();
|
|
std::fs::write(&wait_file, "ok").unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let messages = loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let failed = messages.as_array().unwrap().iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Pet Studio Skill 输出的 petpack-source 不可用")
|
|
});
|
|
if failed {
|
|
break messages;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not fail when Skill produced invalid petpack-source"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
};
|
|
|
|
assert!(messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("修复 Codex App Server / Skill 后重试")
|
|
}));
|
|
let job = state.database.generation_job(job_id).unwrap().unwrap();
|
|
assert_eq!(job.status, GenerationJobStatus::Failed);
|
|
assert!(job.recoverable);
|
|
assert!(job.ended_at.is_none());
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(snapshot["pets"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_external_full_source_rejects_brief_only_materialization() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_brief_only_external_source");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _disable_local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "0");
|
|
let _strict_full_source = EnvVarGuard::set("APC_REQUIRE_SKILL_FULL_SOURCE", "1");
|
|
let _strict_external_source = EnvVarGuard::set("APC_REQUIRE_EXTERNAL_SKILL_SOURCE", "1");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "App Server 只返回 brief 时必须不能冒充外部 full source。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let messages = loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let failed = messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("连续 3 个续接段没有检测到新的持久化产物")
|
|
});
|
|
if failed {
|
|
break messages;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"external full-source generation did not reject brief-only App Server output: {}",
|
|
serde_json::to_string_pretty(&messages).unwrap()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
};
|
|
|
|
assert!(messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Codex App Server 连接本身正常")
|
|
}));
|
|
assert_eq!(
|
|
messages
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|message| message["kind"].as_str() == Some("generation_checkpoint"))
|
|
.count(),
|
|
3
|
|
);
|
|
assert!(!messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Agent 连接中修复 Codex App Server")
|
|
}));
|
|
assert!(!messages.as_array().unwrap().iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("内置 Pet Studio Skill 写出完整 petpack-source")
|
|
}));
|
|
let source_dir = state.paths.jobs_dir.join(job_id).join("petpack-source");
|
|
assert!(!source_dir.join("manifest.json").exists());
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(snapshot["pets"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_external_full_source_rejects_injected_deterministic_preview() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let wait_file = temp.path().join("allow-helper-source-complete");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_external_helper_source");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
|
|
let _disable_local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "0");
|
|
let _strict_full_source = EnvVarGuard::set("APC_REQUIRE_SKILL_FULL_SOURCE", "1");
|
|
let _strict_external_source = EnvVarGuard::set("APC_REQUIRE_EXTERNAL_SKILL_SOURCE", "1");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "App Server 调用 Skill helper 写出完整外部 source。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
let job_dir = state.paths.jobs_dir.join(job_id);
|
|
let helper_path = job_dir.join("injected_preview_fixture.py");
|
|
let form_path = job_dir.join("apc_skill_form.json");
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while !form_path.is_file() {
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"external Skill form contract was not staged in the job workspace"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
assert!(
|
|
!helper_path.exists(),
|
|
"external full-source mode must not stage a deterministic preview fixture"
|
|
);
|
|
std::fs::write(
|
|
&helper_path,
|
|
include_str!("fixtures/deterministic-studio-preview-helper.py"),
|
|
)
|
|
.unwrap();
|
|
let helper_output = Command::new("python3")
|
|
.arg("injected_preview_fixture.py")
|
|
.current_dir(&job_dir)
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
helper_output.status.success(),
|
|
"helper failed: stdout={} stderr={}",
|
|
String::from_utf8_lossy(&helper_output.stdout),
|
|
String::from_utf8_lossy(&helper_output.stderr)
|
|
);
|
|
std::fs::write(&wait_file, "ok").unwrap();
|
|
|
|
// The injected fixture writes complete fixed-timing preview frames. Rejection still decodes the
|
|
// full source contract, which can take longer while CI runs other image-heavy
|
|
// generation tests in parallel.
|
|
let deadline = Instant::now() + Duration::from_secs(120);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let rejected = messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("deterministic_preview")
|
|
});
|
|
if rejected {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"deterministic preview helper was not rejected: {}",
|
|
serde_json::to_string_pretty(&messages).unwrap()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
let source_path = job_dir
|
|
.join("petpack-source")
|
|
.join("source")
|
|
.join("source.json");
|
|
let source: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(source_path).unwrap()).unwrap();
|
|
assert_eq!(source["generator"], "petcore-deterministic-preview");
|
|
assert_eq!(source["provenance"], "deterministic_preview");
|
|
assert_eq!(
|
|
source["skill_helper"],
|
|
"petcore-deterministic-preview-fixture-v1"
|
|
);
|
|
assert_eq!(source["preview_only"], true);
|
|
assert_eq!(source["states"][0]["name"], "idle");
|
|
assert_eq!(
|
|
source["states"][0]["frame_durations_ms"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
6
|
|
);
|
|
assert_eq!(source["state_frame_counts"]["idle"], 6);
|
|
assert_eq!(source["state_frame_counts"]["thinking"], 4);
|
|
assert_eq!(source["state_frame_counts"]["waiting"], 8);
|
|
assert_eq!(source["state_frame_counts"]["failed"], 8);
|
|
assert!(source.get("materialized_by").is_none());
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(snapshot["pets"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn generation_builtin_full_source_marks_internal_materializer() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_builtin_full_source");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _disable_local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "0");
|
|
let _strict_full_source = EnvVarGuard::set("APC_REQUIRE_SKILL_FULL_SOURCE", "1");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "App 默认 full source 模式允许内置 materializer 写出完整 source。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
// Strict full-source materialization decodes every generated state and can run
|
|
// alongside other image-heavy integration tests on a smaller CI runner.
|
|
let deadline = Instant::now() + Duration::from_secs(240);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let completed = messages
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|message| message["kind"].as_str() == Some("generation_completed"));
|
|
if completed {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"built-in full-source materializer did not complete: {}",
|
|
serde_json::to_string_pretty(&messages).unwrap()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
let source_path = state
|
|
.paths
|
|
.jobs_dir
|
|
.join(job_id)
|
|
.join("petpack-source")
|
|
.join("source")
|
|
.join("source.json");
|
|
let source: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(source_path).unwrap()).unwrap();
|
|
assert_eq!(source["generator"], "petcore-deterministic-preview");
|
|
assert_eq!(source["provenance"], "deterministic_preview");
|
|
assert_eq!(
|
|
source["materialized_by"],
|
|
"petcore-internal-skill-materializer"
|
|
);
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
assert_eq!(pets[0]["origin"], "generated_by_petcore_job");
|
|
}
|
|
|
|
#[test]
|
|
fn generation_fails_when_all_reference_images_are_unusable() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let app_server_process_file = temp.path().join("app-server-must-not-start");
|
|
write_fake_app_server_script_with_marker(
|
|
&fake_app_server,
|
|
"thread_fake_unusable_reference",
|
|
Some(&app_server_process_file),
|
|
);
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
let missing_reference = temp
|
|
.path()
|
|
.join("TOP_SECRET_REFERENCE_TOKEN")
|
|
.join("private-customer-name.png");
|
|
let missing_reference_path = missing_reference.display().to_string();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "需要参考图的宠物。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": [missing_reference_path.clone()]
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let failure_content = loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
if let Some(content) = messages.as_array().unwrap().iter().find_map(|message| {
|
|
(message["kind"].as_str() == Some("generation_failed"))
|
|
.then(|| message["content"].as_str().unwrap_or_default().to_string())
|
|
}) {
|
|
break content;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not report unusable reference image"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
};
|
|
|
|
assert_eq!(
|
|
state.database.generation_job_status(job_id).unwrap(),
|
|
Some(GenerationJobStatus::Failed)
|
|
);
|
|
assert!(state.database.list_pets().unwrap().is_empty());
|
|
assert!(failure_content.contains("参考图 #1"), "{failure_content}");
|
|
assert!(
|
|
!failure_content.contains("TOP_SECRET_REFERENCE_TOKEN"),
|
|
"{failure_content}"
|
|
);
|
|
assert!(
|
|
!failure_content.contains("private-customer-name"),
|
|
"{failure_content}"
|
|
);
|
|
assert!(
|
|
!failure_content.contains(&missing_reference_path),
|
|
"{failure_content}"
|
|
);
|
|
assert!(
|
|
!app_server_process_file.exists(),
|
|
"App Server started before reference-image staging failed"
|
|
);
|
|
assert!(!state
|
|
.paths
|
|
.jobs_dir
|
|
.join(job_id)
|
|
.join("app_server_session.json")
|
|
.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn generation_cancel_stops_running_job_without_importing_pet() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let wait_file = temp.path().join("allow-app-server-complete");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_cancel");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "取消中的桌宠生成。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let turn_started = messages.as_array().unwrap().iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Pet Studio brief turn 已启动")
|
|
});
|
|
if turn_started {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not reach running turn"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
let cancel_messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.cancel".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(cancel_messages.as_array().unwrap().iter().any(|message| {
|
|
message["progress"].as_f64() == Some(1.0)
|
|
&& message["kind"].as_str() == Some("generation_canceled")
|
|
&& message["content"].as_str().unwrap_or("") == "已取消生成。"
|
|
}));
|
|
assert!(state.paths.jobs_dir.join(job_id).join("canceled").is_file());
|
|
let reply_after_cancel = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.reply".to_string(),
|
|
params: json!({
|
|
"job_id": job_id,
|
|
"content": "取消后的回复不能触发调整"
|
|
}),
|
|
},
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(reply_after_cancel.contains("generation was canceled"));
|
|
|
|
std::fs::write(&wait_file, "ok").unwrap();
|
|
std::thread::sleep(Duration::from_millis(600));
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(snapshot["pets"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_reply_is_rejected_while_job_is_running() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let wait_file = temp.path().join("allow-app-server-complete");
|
|
let process_file = temp.path().join("fake-app-server-process");
|
|
write_fake_app_server_script_with_marker(
|
|
&fake_app_server,
|
|
"thread_fake_running_reply",
|
|
Some(&process_file),
|
|
);
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "运行中回复应被拒绝。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let turn_started = messages.as_array().unwrap().iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Pet Studio brief turn 已启动")
|
|
});
|
|
if turn_started {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not reach running turn"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
let error = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.reply".to_string(),
|
|
params: json!({
|
|
"job_id": job_id,
|
|
"content": "这条运行中回复不能静默记录"
|
|
}),
|
|
},
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(error.contains("generation is still running"));
|
|
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(!messages.as_array().unwrap().iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("这条运行中回复不能静默记录")
|
|
}));
|
|
|
|
let process_group = wait_for_fixture_process_group(&process_file);
|
|
assert_ne!(
|
|
process_group,
|
|
getpgrp(),
|
|
"fake App Server must not share the test runner's process group"
|
|
);
|
|
assert!(
|
|
test_kill_process_group(process_group).is_ok(),
|
|
"fake App Server process group exited before cancellation"
|
|
);
|
|
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.cancel".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let app_server_session = state
|
|
.paths
|
|
.jobs_dir
|
|
.join(job_id)
|
|
.join("app_server_session.json");
|
|
wait_for_generation_worker_cleanup(&app_server_session, process_group);
|
|
let session: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(app_server_session).unwrap()).unwrap();
|
|
assert_eq!(session["error"], "generation canceled");
|
|
}
|
|
|
|
#[test]
|
|
fn generation_retry_creates_tracked_job_from_previous_form() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let database = Database::new(paths.db_path.clone());
|
|
paths.ensure().unwrap();
|
|
database.init().unwrap();
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", temp.path().join("missing-server"));
|
|
let _disable_auto = EnvVarGuard::set("APC_DISABLE_CODEX_APP_SERVER_AUTO", "1");
|
|
|
|
let form = GenerationForm {
|
|
description: "可重试的宠物".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: vec![],
|
|
};
|
|
|
|
let failed_dir = paths.jobs_dir.join("job_retry_source");
|
|
std::fs::create_dir_all(&failed_dir).unwrap();
|
|
database
|
|
.create_generation_job("job_retry_source", &form, &failed_dir)
|
|
.unwrap();
|
|
database
|
|
.update_generation_job("job_retry_source", GenerationJobStatus::Failed, None)
|
|
.unwrap();
|
|
|
|
let retry_id = generation::retry_generation(&paths, &database, "job_retry_source", None)
|
|
.expect("failed job should be retryable");
|
|
let retry_job = database.generation_job(&retry_id).unwrap().unwrap();
|
|
assert_eq!(retry_job.session_id, None);
|
|
assert_eq!(
|
|
retry_job.retry_of_job_id.as_deref(),
|
|
Some("job_retry_source")
|
|
);
|
|
let retry_form: GenerationForm = serde_json::from_str(&retry_job.form_json).unwrap();
|
|
assert_eq!(retry_form.description, form.description);
|
|
generation::cancel_generation(&paths, &database, &retry_id).unwrap();
|
|
|
|
let running_dir = paths.jobs_dir.join("job_running_source");
|
|
std::fs::create_dir_all(&running_dir).unwrap();
|
|
database
|
|
.create_generation_job("job_running_source", &form, &running_dir)
|
|
.unwrap();
|
|
database
|
|
.update_generation_job("job_running_source", GenerationJobStatus::Running, None)
|
|
.unwrap();
|
|
let error = generation::retry_generation(&paths, &database, "job_running_source", None)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(error.contains("not retryable"));
|
|
}
|
|
|
|
#[test]
|
|
fn generation_resume_reuses_recoverable_job_workspace_and_codex_thread() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let database = Database::new(paths.db_path.clone());
|
|
paths.ensure().unwrap();
|
|
database.init().unwrap();
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "");
|
|
let _disable_auto = EnvVarGuard::set("APC_DISABLE_CODEX_APP_SERVER_AUTO", "1");
|
|
|
|
let form = GenerationForm {
|
|
description: "从原工作区继续的宠物".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: vec![],
|
|
};
|
|
let job_id = "job_resume_same_workspace";
|
|
let job_dir = paths.jobs_dir.join(job_id);
|
|
std::fs::create_dir_all(job_dir.join("petpack-source")).unwrap();
|
|
std::fs::write(job_dir.join("petpack-source/accepted-state.txt"), "passed").unwrap();
|
|
database
|
|
.create_generation_job(job_id, &form, &job_dir)
|
|
.unwrap();
|
|
database
|
|
.update_generation_job_session(job_id, "thread_resume_same_workspace")
|
|
.unwrap();
|
|
database
|
|
.mark_generation_recoverable_failure(job_id, "fixture_failure", "fixture paused")
|
|
.unwrap();
|
|
|
|
let resumed =
|
|
generation::resume_generation_for_instance(&paths, &database, job_id, "test-instance")
|
|
.unwrap();
|
|
assert_eq!(resumed, job_id);
|
|
assert_eq!(database.generation_jobs(10).unwrap().len(), 1);
|
|
let resumed_job = database.generation_job(job_id).unwrap().unwrap();
|
|
assert_eq!(
|
|
resumed_job.session_id.as_deref(),
|
|
Some("thread_resume_same_workspace")
|
|
);
|
|
assert!(job_dir.join("petpack-source/accepted-state.txt").is_file());
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
loop {
|
|
let messages = database.generation_messages(job_id).unwrap();
|
|
let resumed_message = messages
|
|
.iter()
|
|
.any(|message| message.kind.as_deref() == Some("generation_resumed"));
|
|
let worker_stopped_recoverably = messages
|
|
.iter()
|
|
.any(|message| message.kind.as_deref() == Some("recoverable_error"));
|
|
if resumed_message && worker_stopped_recoverably {
|
|
assert!(messages.iter().any(|message| {
|
|
message.kind.as_deref() == Some("generation_resumed")
|
|
&& message
|
|
.content
|
|
.contains("保留原 job、Codex 会话、素材与已通过的 QA")
|
|
}));
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"resume message was not persisted"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generation_heartbeat_wakes_live_wait_without_growing_the_timeline() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let database = Database::new(paths.db_path.clone());
|
|
paths.ensure().unwrap();
|
|
database.init().unwrap();
|
|
let form = GenerationForm {
|
|
description: "heartbeat projection".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: vec![],
|
|
};
|
|
let job_id = "job_heartbeat_projection";
|
|
let job_dir = paths.jobs_dir.join(job_id);
|
|
std::fs::create_dir_all(&job_dir).unwrap();
|
|
database
|
|
.create_generation_job(job_id, &form, &job_dir)
|
|
.unwrap();
|
|
database
|
|
.update_generation_job(job_id, GenerationJobStatus::Running, None)
|
|
.unwrap();
|
|
|
|
let initial =
|
|
generation::wait_messages_with_database(&paths, &database, job_id, "", 250).unwrap();
|
|
let revision = initial["revision"].as_str().unwrap().to_string();
|
|
let initial_heartbeat = initial["heartbeat_at"].as_str().unwrap().to_string();
|
|
assert_eq!(initial["messages"].as_array().unwrap().len(), 0);
|
|
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
database.touch_generation_job(job_id).unwrap();
|
|
let update =
|
|
generation::wait_messages_with_database(&paths, &database, job_id, &revision, 250).unwrap();
|
|
|
|
assert_eq!(update["changed"], true);
|
|
assert_ne!(update["revision"], revision);
|
|
assert_ne!(update["heartbeat_at"], initial_heartbeat);
|
|
assert_eq!(update["messages"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_waits_for_user_input_and_resumes_after_reply() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server_input_request.sh");
|
|
write_fake_app_server_input_request_script(&fake_app_server, "thread_fake_input_request");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "1");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "需要追问的桌宠。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let waiting = messages.as_array().unwrap().iter().any(|message| {
|
|
message["kind"].as_str() == Some("input_request")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("请补充这个桌宠的主体外观")
|
|
});
|
|
if waiting {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not reach input request state"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
assert_eq!(
|
|
state.database.generation_job_status(job_id).unwrap(),
|
|
Some(GenerationJobStatus::WaitingForUser)
|
|
);
|
|
|
|
let reply_messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.reply".to_string(),
|
|
params: json!({
|
|
"job_id": job_id,
|
|
"content": "主体是粉白长裙的东方幻想少女,动作轻盈。"
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(reply_messages.as_array().unwrap().iter().any(|message| {
|
|
message["role"].as_str() == Some("user")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("粉白长裙")
|
|
}));
|
|
|
|
// The resumed turn still performs a full nine-action materialization. It can
|
|
// share the runner with other image-heavy integration tests.
|
|
let deadline = Instant::now() + Duration::from_secs(240);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let completed = messages
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|message| message["kind"].as_str() == Some("generation_completed"));
|
|
if completed {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not complete after user input; messages={messages}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
assert_eq!(
|
|
state.database.generation_job_status(job_id).unwrap(),
|
|
Some(GenerationJobStatus::Completed)
|
|
);
|
|
assert_eq!(state.database.list_pets().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_ready_marks_stale_interrupted_generation_job_recoverable() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths.clone());
|
|
state.ensure_ready().unwrap();
|
|
|
|
let form = GenerationForm {
|
|
description: "中断恢复测试".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: Vec::new(),
|
|
};
|
|
let running_dir = paths.jobs_dir.join("job_interrupted_running");
|
|
std::fs::create_dir_all(&running_dir).unwrap();
|
|
state
|
|
.database
|
|
.create_generation_job("job_interrupted_running", &form, &running_dir)
|
|
.unwrap();
|
|
state
|
|
.database
|
|
.update_generation_job(
|
|
"job_interrupted_running",
|
|
GenerationJobStatus::Running,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
rusqlite::Connection::open(state.database.path())
|
|
.unwrap()
|
|
.execute(
|
|
"UPDATE generation_jobs SET heartbeat_at = '2000-01-01T00:00:00Z' WHERE id = ?1",
|
|
["job_interrupted_running"],
|
|
)
|
|
.unwrap();
|
|
|
|
let restarted = CoreState::new(paths);
|
|
restarted.ensure_ready().unwrap();
|
|
|
|
let job_id = "job_interrupted_running";
|
|
assert_eq!(
|
|
restarted.database.generation_job_status(job_id).unwrap(),
|
|
Some(GenerationJobStatus::Failed)
|
|
);
|
|
let recovered = restarted.database.generation_job(job_id).unwrap().unwrap();
|
|
assert!(recovered.recoverable);
|
|
assert_eq!(recovered.failure_code.as_deref(), Some("owner_interrupted"));
|
|
assert!(recovered.ended_at.is_none());
|
|
let messages = petcore::generation::read_messages(&restarted.paths, job_id).unwrap();
|
|
assert!(messages.iter().any(|message| {
|
|
message["kind"].as_str() == Some("recoverable_error")
|
|
&& message["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("制作已暂停")
|
|
}));
|
|
assert!(restarted
|
|
.database
|
|
.interrupted_generation_jobs()
|
|
.unwrap()
|
|
.is_empty());
|
|
assert_eq!(restarted.database.list_pets().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_builds_form_driven_petpack_with_cover_and_source() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_pet_studio");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _disable_local_fallback = EnvVarGuard::set("APC_ALLOW_LOCAL_PET_STUDIO_FALLBACK", "0");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
let reference_path = temp.path().join("reference.png");
|
|
ImageBuffer::from_pixel(12, 12, Rgba([240u8, 210, 220, 255]))
|
|
.save(&reference_path)
|
|
.unwrap();
|
|
let original_reference_path = reference_path.display().to_string();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "安静陪伴的东方幻想角色,工作时衣摆发光。",
|
|
"style": "半写实",
|
|
"quality": "standard",
|
|
"reference_images": [original_reference_path.clone()]
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
wait_for_file(
|
|
&state
|
|
.paths
|
|
.jobs_dir
|
|
.join(job_id)
|
|
.join("input/references/reference-00.png"),
|
|
);
|
|
|
|
// Full nine-action materialization decodes and repacks every animation while
|
|
// other image-heavy integration tests may be running on the same host.
|
|
let deadline = Instant::now() + Duration::from_secs(240);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let items = messages.as_array().unwrap();
|
|
if items
|
|
.iter()
|
|
.any(|message| message["kind"].as_str() == Some("generation_completed"))
|
|
{
|
|
break;
|
|
}
|
|
if items.iter().any(|message| {
|
|
matches!(
|
|
message["kind"].as_str(),
|
|
Some("generation_failed" | "generation_canceled")
|
|
)
|
|
}) {
|
|
let app_server_session = std::fs::read_to_string(
|
|
state
|
|
.paths
|
|
.jobs_dir
|
|
.join(job_id)
|
|
.join("app_server_session.json"),
|
|
)
|
|
.unwrap_or_else(|_| "<missing>".to_string());
|
|
panic!(
|
|
"generation ended without the required App Server brief: \
|
|
messages={messages}; app_server_session={app_server_session}"
|
|
);
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"generation did not complete: {messages}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
let pet = &pets[0];
|
|
assert_eq!(pet["name"], "AI 云袖");
|
|
assert_eq!(pet["origin"], "generated_by_petcore_job");
|
|
assert_eq!(pet["generator"], "codex-app-server-brief-petpack-v3");
|
|
assert_eq!(pet["provenance"], "codex_app_server_brief");
|
|
assert!(std::path::Path::new(pet["cover_path"].as_str().unwrap()).is_file());
|
|
|
|
let petpack_path = std::path::Path::new(pet["petpack_path"].as_str().unwrap());
|
|
let frames_dir = petpack_path
|
|
.parent()
|
|
.unwrap()
|
|
.join(format!("{}-frames", pet["id"].as_str().unwrap()));
|
|
assert!(frames_dir.join("idle/0000.png").is_file());
|
|
assert!(frames_dir.join("tool/0003.png").is_file());
|
|
std::fs::remove_dir_all(&frames_dir).unwrap();
|
|
handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(frames_dir.join("idle/0000.png").is_file());
|
|
assert!(frames_dir.join("tool/0003.png").is_file());
|
|
let idle_frame = image::open(frames_dir.join("idle/0000.png"))
|
|
.unwrap()
|
|
.to_rgba8();
|
|
assert_eq!(idle_frame.get_pixel(192, 208).0, [240, 210, 220, 255]);
|
|
let validation = validate_petpack_path(petpack_path).unwrap();
|
|
assert_eq!(validation.manifest.name, "AI 云袖");
|
|
|
|
let final_messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let final_items = final_messages.as_array().unwrap();
|
|
assert!(final_items.iter().all(|message| {
|
|
let kind = message["kind"].as_str().unwrap_or("");
|
|
let user_visible_progress =
|
|
kind == "generation_progress" || kind.starts_with("generation_activity_");
|
|
let content = message["content"].as_str().unwrap_or("");
|
|
!user_visible_progress
|
|
|| (!content.contains("thread_fake_pet_studio")
|
|
&& !content.contains("turn_fake_pet_studio"))
|
|
}));
|
|
assert!(final_items.iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("Pet Studio brief turn 已启动")
|
|
}));
|
|
assert!(final_items.iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("Codex 正在生成宠物 brief")
|
|
}));
|
|
assert!(final_items.iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("已收到 Codex 阶段性回复")
|
|
}));
|
|
assert!(final_items.iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("Codex turn 已完成")
|
|
}));
|
|
assert!(final_items.iter().any(|message| {
|
|
message["content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("已按固定九动作 petpack V3 契约补齐")
|
|
}));
|
|
|
|
let file = std::fs::File::open(petpack_path).unwrap();
|
|
let mut archive = zip::ZipArchive::new(file).unwrap();
|
|
assert!(archive.by_name("assets/preview/cover.png").is_ok());
|
|
assert!(archive
|
|
.by_name("assets/preview/animated_preview.webp")
|
|
.is_ok());
|
|
assert!(archive.by_name("source/prompt.md").is_ok());
|
|
assert!(archive.by_name("source/source.json").is_ok());
|
|
assert!(archive
|
|
.by_name("source/references/reference-00.png")
|
|
.is_ok());
|
|
let mut prompt = String::new();
|
|
std::io::Read::read_to_string(
|
|
&mut archive.by_name("source/prompt.md").unwrap(),
|
|
&mut prompt,
|
|
)
|
|
.unwrap();
|
|
assert!(prompt.contains("source/references/reference-00.png"));
|
|
assert!(!prompt.contains(&original_reference_path));
|
|
let mut skill_session = String::new();
|
|
std::io::Read::read_to_string(
|
|
&mut archive.by_name("source/skill_session.jsonl").unwrap(),
|
|
&mut skill_session,
|
|
)
|
|
.unwrap();
|
|
assert!(skill_session.contains("agent-pet-studio"));
|
|
assert!(skill_session.contains("codex_thread.started"));
|
|
assert!(skill_session.contains("codex_turn.completed"));
|
|
assert!(!skill_session.contains("thread_fake_pet_studio"));
|
|
assert!(!skill_session.contains("turn_fake_pet_studio"));
|
|
assert!(!skill_session.contains("studio.messages"));
|
|
assert!(skill_session.contains("states.rendered"));
|
|
assert!(skill_session.contains("\"state_timings\""));
|
|
assert!(skill_session.contains("\"frame_durations_ms\""));
|
|
assert!(skill_session.contains("\"state_frame_counts\""));
|
|
assert!(skill_session.contains("\"reference_count\":1"));
|
|
assert!(!skill_session.contains(&original_reference_path));
|
|
for line in skill_session.lines().filter(|line| !line.trim().is_empty()) {
|
|
let event: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
assert_matches_petpack_metadata_schema("pet-source-event.schema.json", &event);
|
|
}
|
|
let brief: serde_json::Value =
|
|
serde_json::from_reader(archive.by_name("brief.json").unwrap()).unwrap();
|
|
assert_matches_petpack_metadata_schema("pet-brief.schema.json", &brief);
|
|
assert_eq!(
|
|
brief["description"],
|
|
"安静陪伴的东方幻想角色,工作时衣摆发光。"
|
|
);
|
|
assert_eq!(brief["ai_brief"]["name"], "AI 云袖");
|
|
assert_eq!(brief["ai_brief"]["visual_brief"], "AI brief");
|
|
assert_eq!(brief["ai_brief"]["states"].as_array().unwrap().len(), 9);
|
|
assert_eq!(
|
|
brief["generation"]["generator"],
|
|
"codex-app-server-brief-petpack-v3"
|
|
);
|
|
assert_eq!(brief["generation"]["provenance"], "codex_app_server_brief");
|
|
assert_eq!(brief["states"][0]["motion"], "breathing");
|
|
assert_eq!(brief["states"][1]["name"], "thinking");
|
|
assert_eq!(brief["states"][1]["motion"], "抬头并进入思考状态");
|
|
let source: serde_json::Value =
|
|
serde_json::from_reader(archive.by_name("source/source.json").unwrap()).unwrap();
|
|
assert_matches_petpack_metadata_schema("pet-source.schema.json", &source);
|
|
assert_eq!(source["generator"], "codex-app-server-brief-petpack-v3");
|
|
assert_eq!(source["provenance"], "codex_app_server_brief");
|
|
assert_eq!(source["palette_source"], "codex-ai-brief");
|
|
assert_eq!(source["palette"]["source"], "codex-ai-brief");
|
|
assert_eq!(source["visual_source"], "reference-image");
|
|
assert_eq!(source["states"][0]["name"], "idle");
|
|
assert_eq!(
|
|
source["states"][0]["frame_durations_ms"],
|
|
json!([260, 220, 240, 260, 380, 640])
|
|
);
|
|
assert_eq!(source["states"][0]["playback"]["mode"], "periodic");
|
|
assert_eq!(source["state_frame_counts"]["idle"], 6);
|
|
assert_eq!(
|
|
source["form"]["reference_images"][0],
|
|
"source/references/reference-00.png"
|
|
);
|
|
assert_eq!(
|
|
source["reference_files"][0],
|
|
"source/references/reference-00.png"
|
|
);
|
|
assert_eq!(source["input_reference_count"], 1);
|
|
assert_eq!(source["copied_reference_count"], 1);
|
|
assert!(!source.to_string().contains(&original_reference_path));
|
|
let mut cover_bytes = Vec::new();
|
|
std::io::Read::read_to_end(
|
|
&mut archive.by_name("assets/preview/cover.png").unwrap(),
|
|
&mut cover_bytes,
|
|
)
|
|
.unwrap();
|
|
let cover = image::load_from_memory(&cover_bytes).unwrap().to_rgba8();
|
|
assert_eq!(cover.get_pixel(192, 208).0, [240, 210, 220, 255]);
|
|
let validation_metadata: serde_json::Value =
|
|
serde_json::from_reader(archive.by_name("build/validation.json").unwrap()).unwrap();
|
|
assert_matches_petpack_metadata_schema("pet-validation.schema.json", &validation_metadata);
|
|
assert_eq!(
|
|
validation_metadata["generator"],
|
|
"codex-app-server-brief-petpack-v3"
|
|
);
|
|
assert_eq!(validation_metadata["provenance"], "codex_app_server_brief");
|
|
assert_eq!(validation_metadata["states"][1]["name"], "thinking");
|
|
assert_eq!(validation_metadata["state_frame_counts"]["thinking"], 4);
|
|
|
|
let reply_error = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.reply".to_string(),
|
|
params: json!({
|
|
"job_id": job_id,
|
|
"content": "等待确认动作再明显一点"
|
|
}),
|
|
},
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
reply_error.contains("generation is completed"),
|
|
"{reply_error}"
|
|
);
|
|
assert_eq!(state.database.list_pets().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_imports_codex_skill_petpack_source_when_present() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let wait_file = temp.path().join("allow-app-server-complete");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_skill_petpack");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "由 Skill 写出的完整 petpack source。",
|
|
"style": "现代",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
let source_dir = state.paths.jobs_dir.join(job_id).join("petpack-source");
|
|
let manifest = write_sample_petpack_dir(
|
|
&source_dir,
|
|
QualityLevel::Standard,
|
|
"Skill Rendered Pet",
|
|
"现代",
|
|
)
|
|
.unwrap();
|
|
let source_metadata_dir = source_dir.join("source");
|
|
std::fs::create_dir_all(&source_metadata_dir).unwrap();
|
|
let state_frame_counts = manifest
|
|
.states
|
|
.iter()
|
|
.map(|state| (state.name, state.frame_durations_ms.len()))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
std::fs::write(
|
|
source_metadata_dir.join("source.json"),
|
|
serde_json::to_vec_pretty(&json!({
|
|
"schema_version": "apc.pet-source.v1",
|
|
"generator": "codex-app-server-skill",
|
|
"provenance": "skill-full-source",
|
|
"manifest_id": manifest.id,
|
|
"pet_name": "Skill Rendered Pet",
|
|
"quality": manifest.quality,
|
|
"visual_source": "image-generation",
|
|
"states": manifest.states,
|
|
"state_frame_counts": state_frame_counts,
|
|
"preview_only": false
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
source_metadata_dir.join("skill_session.jsonl"),
|
|
serde_json::to_string(&json!({
|
|
"event": "skill.petpack_source.written",
|
|
"runner": "codex-app-server"
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(&wait_file, "ok").unwrap();
|
|
|
|
// A strict skill-full-source import decodes every PNG and the animated
|
|
// preview before publishing. A standalone debug import is close to two
|
|
// minutes on the largest supported fixture, so retain real headroom when
|
|
// the full suite concurrently validates both bundled petpacks.
|
|
let deadline = Instant::now() + Duration::from_secs(300);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let completed = messages
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|message| message["kind"].as_str() == Some("generation_completed"));
|
|
if completed {
|
|
break;
|
|
}
|
|
let failed = messages
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|message| message["kind"].as_str() == Some("generation_failed"));
|
|
assert!(
|
|
!failed,
|
|
"skill petpack source import failed: {}",
|
|
serde_json::to_string_pretty(&messages).unwrap()
|
|
);
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"skill petpack source was not imported: {}",
|
|
serde_json::to_string_pretty(&messages).unwrap()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let pets = snapshot["pets"].as_array().unwrap();
|
|
assert_eq!(pets.len(), 1);
|
|
assert_eq!(pets[0]["name"], "Skill Rendered Pet");
|
|
assert_eq!(pets[0]["origin"], "verified_skill_source");
|
|
assert_eq!(pets[0]["generator"], "codex-app-server-skill");
|
|
assert_eq!(pets[0]["provenance"], "skill-full-source");
|
|
let petpack_path = std::path::Path::new(pets[0]["petpack_path"].as_str().unwrap());
|
|
let validation = validate_petpack_path(petpack_path).unwrap();
|
|
assert_eq!(validation.manifest.id, manifest.id);
|
|
|
|
let file = std::fs::File::open(petpack_path).unwrap();
|
|
let mut archive = zip::ZipArchive::new(file).unwrap();
|
|
assert!(archive
|
|
.by_name("assets/preview/animated_preview.webp")
|
|
.is_ok());
|
|
let source: serde_json::Value =
|
|
serde_json::from_reader(archive.by_name("source/source.json").unwrap()).unwrap();
|
|
assert_eq!(source["generator"], "codex-app-server-skill");
|
|
assert_eq!(source["provenance"], "skill-full-source");
|
|
let mut skill_session = String::new();
|
|
std::io::Read::read_to_string(
|
|
&mut archive.by_name("source/skill_session.jsonl").unwrap(),
|
|
&mut skill_session,
|
|
)
|
|
.unwrap();
|
|
assert!(skill_session.contains("skill.loaded"));
|
|
assert!(!skill_session.contains("skill.petpack_source.written"));
|
|
}
|
|
|
|
#[test]
|
|
fn generation_rejects_provider_symlink_before_portable_metadata_write() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_app_server = temp.path().join("fake_app_server.sh");
|
|
let wait_file = temp.path().join("allow-app-server-complete");
|
|
write_fake_app_server_script(&fake_app_server, "thread_fake_skill_symlink");
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
|
|
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let start = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.start".to_string(),
|
|
params: json!({
|
|
"description": "Provider symlink must not escape the job directory.",
|
|
"style": "现代",
|
|
"quality": "standard",
|
|
"reference_images": []
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let job_id = start["job_id"].as_str().unwrap();
|
|
let source_dir = state.paths.jobs_dir.join(job_id).join("petpack-source");
|
|
write_sample_petpack_dir(
|
|
&source_dir,
|
|
QualityLevel::Standard,
|
|
"Symlink Provider Pet",
|
|
"现代",
|
|
)
|
|
.unwrap();
|
|
|
|
let outside = temp.path().join("outside-private-data.json");
|
|
let outside_marker = br#"{"private":"must remain unchanged"}"#;
|
|
std::fs::write(&outside, outside_marker).unwrap();
|
|
let provider_metadata = source_dir.join("source/source.json");
|
|
std::fs::remove_file(&provider_metadata).unwrap();
|
|
std::os::unix::fs::symlink(&outside, &provider_metadata).unwrap();
|
|
std::fs::write(&wait_file, "continue").unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(8);
|
|
loop {
|
|
let messages = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "generation.messages".to_string(),
|
|
params: json!({ "job_id": job_id }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
if messages
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|message| message["kind"].as_str() == Some("recoverable_error"))
|
|
{
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"provider symlink generation did not fail: {}",
|
|
serde_json::to_string_pretty(&messages).unwrap()
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
assert_eq!(std::fs::read(&outside).unwrap(), outside_marker);
|
|
assert!(std::fs::symlink_metadata(&provider_metadata)
|
|
.unwrap()
|
|
.file_type()
|
|
.is_symlink());
|
|
assert!(state.database.list_pets().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn repair_generates_real_pi_and_opencode_connectors() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_agent_home = temp.path().join("agent-home");
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
let capture_path = temp.path().join("connector-capture.log");
|
|
write_fake_cli(&fake_cli);
|
|
let _agent_home = EnvVarGuard::set("APC_AGENT_CONFIG_HOME", fake_agent_home.as_os_str());
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", fake_cli.as_os_str());
|
|
let _app_server_cmd = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "");
|
|
let _disable_app_server_auto = EnvVarGuard::set("APC_DISABLE_CODEX_APP_SERVER_AUTO", "1");
|
|
let paths = AppPaths::new(temp.path().to_path_buf());
|
|
paths.ensure().unwrap();
|
|
|
|
let codex_status = connections::repair_source(&paths, AgentSource::Codex).unwrap();
|
|
let codex_plugin = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion")
|
|
.join(".codex-plugin")
|
|
.join("plugin.json");
|
|
let codex_hooks = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion")
|
|
.join("hooks")
|
|
.join("hooks.json");
|
|
let codex_skill = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion")
|
|
.join("skills")
|
|
.join("agent-pet-studio")
|
|
.join("SKILL.md");
|
|
let codex_maker = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion")
|
|
.join("skills")
|
|
.join("agent-pet-maker");
|
|
let codex_marketplace = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("marketplace.json");
|
|
let codex_marketplace_content = std::fs::read_to_string(&codex_marketplace).unwrap();
|
|
let codex_marketplace_json: serde_json::Value =
|
|
serde_json::from_str(&codex_marketplace_content).unwrap();
|
|
let codex_skill_content = std::fs::read_to_string(&codex_skill).unwrap();
|
|
assert!(codex_plugin.is_file());
|
|
assert!(codex_skill_content.contains(".petpack V3 assets"));
|
|
assert!(codex_skill_content.contains("future-creation timing defaults"));
|
|
assert!(codex_skill_content.contains("petpack-v3.md"));
|
|
assert!(codex_skill_content.contains("APC_PETCORE_CLI"));
|
|
let maker_source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../skills/agent-pet-maker");
|
|
for relative_path in [
|
|
"SKILL.md",
|
|
"references/petpack-v3.md",
|
|
"references/create-modify.md",
|
|
"references/visual-production-and-native-resolution.md",
|
|
"references/dreamina-high-production.md",
|
|
"references/transparent-frame-production.md",
|
|
"references/security.md",
|
|
"scripts/petpack_workspace.py",
|
|
"scripts/prepare_transparent_frames.py",
|
|
"agents/openai.yaml",
|
|
"tests/test_petpack_workspace.py",
|
|
"tests/test_prepare_transparent_frames.py",
|
|
] {
|
|
assert_eq!(
|
|
std::fs::read(codex_maker.join(relative_path)).unwrap(),
|
|
std::fs::read(maker_source.join(relative_path)).unwrap(),
|
|
"Codex repair must install the complete agent-pet-maker file {relative_path}"
|
|
);
|
|
}
|
|
let maker_openai_agent =
|
|
std::fs::read_to_string(codex_maker.join("agents/openai.yaml")).unwrap();
|
|
assert!(
|
|
maker_openai_agent.contains("$agent-pet-companion:agent-pet-maker"),
|
|
"Codex plugin skills must use the plugin-qualified invocation so the CLI injects the skill body"
|
|
);
|
|
assert_ne!(
|
|
std::fs::metadata(codex_maker.join("scripts/petpack_workspace.py"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o111,
|
|
0,
|
|
"the installed agent-pet-maker helper must remain executable"
|
|
);
|
|
assert_ne!(
|
|
std::fs::metadata(codex_maker.join("scripts/prepare_transparent_frames.py"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o111,
|
|
0,
|
|
"the installed transparent-frame helper must remain executable"
|
|
);
|
|
assert!(codex_marketplace_content.contains("agent-pet-companion"));
|
|
let codex_marketplace_plugin = codex_marketplace_json["plugins"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|plugin| plugin["name"] == "agent-pet-companion")
|
|
.unwrap();
|
|
let expected_codex_plugin_path = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion")
|
|
.display()
|
|
.to_string();
|
|
assert_eq!(
|
|
codex_marketplace_plugin["source"]["path"].as_str(),
|
|
Some(expected_codex_plugin_path.as_str())
|
|
);
|
|
assert!(codex_status
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "插件源" && item.status == CheckStatus::Ok));
|
|
assert!(codex_status
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "Codex marketplace" && item.status == CheckStatus::Ok));
|
|
assert!(codex_status.items.iter().any(|item| {
|
|
item.name == "Agent Pet Maker Skill"
|
|
&& item.status == CheckStatus::Ok
|
|
&& item
|
|
.detail
|
|
.contains("Codex plugin 可原生发现完整 agent-pet-maker")
|
|
}));
|
|
let codex_hooks_json: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&codex_hooks).unwrap()).unwrap();
|
|
let codex_session_start_command = codex_hooks_json["hooks"]["SessionStart"][0]["hooks"][0]
|
|
["command"]
|
|
.as_str()
|
|
.unwrap();
|
|
run_shell_hook_smoke(
|
|
codex_session_start_command,
|
|
r#"{"hook_event_name":"SessionStart","session_id":"sess_codex_runtime","cwd":"/tmp/codex-project"}"#,
|
|
&capture_path,
|
|
);
|
|
let codex_capture = wait_for_capture(&capture_path, "--source codex");
|
|
assert!(codex_capture.contains("--event-type auto"));
|
|
assert!(codex_capture.contains("sess_codex_runtime"));
|
|
|
|
let claude_status = connections::repair_source(&paths, AgentSource::ClaudeCode).unwrap();
|
|
let claude_settings = fake_agent_home.join(".claude").join("settings.json");
|
|
let claude_settings_content = std::fs::read_to_string(&claude_settings).unwrap();
|
|
assert!(claude_settings_content.contains("agent-pet-companion-hook.sh"));
|
|
assert!(!claude_settings_content.contains("agent hook --source claude_code"));
|
|
assert!(claude_status
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "Claude settings.json" && item.status == CheckStatus::Ok));
|
|
let claude_settings_json: serde_json::Value =
|
|
serde_json::from_str(&claude_settings_content).unwrap();
|
|
let claude_tool_command = claude_settings_json["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
|
|
.as_str()
|
|
.unwrap();
|
|
run_shell_hook_smoke(
|
|
claude_tool_command,
|
|
r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"sess_claude_runtime"}"#,
|
|
&capture_path,
|
|
);
|
|
let claude_settings_capture = wait_for_capture(&capture_path, "--source claude_code");
|
|
assert!(claude_settings_capture.contains("--event-type auto"));
|
|
assert!(claude_settings_capture.contains("sess_claude_runtime"));
|
|
let claude_hook_script = paths
|
|
.connectors_dir
|
|
.join("claude-code")
|
|
.join("agent-pet-companion-hook.sh");
|
|
let claude_helper_content = std::fs::read_to_string(&claude_hook_script).unwrap();
|
|
assert!(claude_helper_content.contains("agent hook --source claude_code"));
|
|
assert!(claude_helper_content.contains("claude-hooks-2026-08-01-events-v9"));
|
|
let mut claude_hook_child = Command::new(&claude_hook_script)
|
|
.env("APC_FAKE_CLI_CAPTURE", &capture_path)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
claude_hook_child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(
|
|
br#"{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"sess_claude_helper_v5"}"#,
|
|
)
|
|
.unwrap();
|
|
let claude_hook_output = claude_hook_child.wait_with_output().unwrap();
|
|
assert!(
|
|
claude_hook_output.status.success(),
|
|
"Claude hook helper failed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&claude_hook_output.stdout),
|
|
String::from_utf8_lossy(&claude_hook_output.stderr)
|
|
);
|
|
let claude_helper_capture = wait_for_capture(&capture_path, "sess_claude_helper_v5");
|
|
assert!(claude_helper_capture.contains("--source claude_code"));
|
|
assert!(claude_helper_capture.contains("--event-type auto"));
|
|
|
|
let pi_status = connections::repair_source(&paths, AgentSource::Pi).unwrap();
|
|
let pi_extension = fake_agent_home
|
|
.join(".pi")
|
|
.join("agent")
|
|
.join("extensions")
|
|
.join("agent-pet-companion.ts");
|
|
let pi_script = std::fs::read_to_string(&pi_extension).unwrap();
|
|
assert!(pi_script.contains("export default function agentPetCompanion(pi)"));
|
|
assert!(pi_script.contains("pi.on(\"agent_settled\""));
|
|
assert!(pi_script.contains("pi.on(\"before_agent_start\""));
|
|
assert!(pi_script.contains("pi.on(\"input\""));
|
|
assert!(pi_script.contains("pi.on(\"message_end\""));
|
|
assert!(pi_script.contains("pi.on(\"agent_end\""));
|
|
assert!(pi_script.contains("pi.on(\"session_shutdown\""));
|
|
assert!(pi_script.contains("session_title: sessionTitle(ctx, event)"));
|
|
assert!(pi_script.contains("message_content: message?.content"));
|
|
assert!(pi_script.contains("agent_error: agentError"));
|
|
assert!(pi_script.contains("assistant?.stopReason === \"error\""));
|
|
assert!(pi_script.contains("session_open: event?.type !== \"session_shutdown\""));
|
|
assert!(pi_script.contains("event?.isError === true"));
|
|
assert!(pi_script.contains("\"--event-type\", \"auto\""));
|
|
assert!(!pi_script.contains("permission_request"));
|
|
assert!(!pi_script.contains("tool_execution_failed"));
|
|
assert!(pi_status
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "Extension" && item.status == CheckStatus::Ok));
|
|
assert!(pi_status.items.iter().any(|item| {
|
|
item.name == "Extension 运行时"
|
|
&& item.status == CheckStatus::Unverified
|
|
&& item.detail.contains("外部事件 CLI 覆盖")
|
|
}));
|
|
assert!(!fake_agent_home
|
|
.join(".pi")
|
|
.join("agent")
|
|
.join("extensions")
|
|
.join("rpc-check.json")
|
|
.exists());
|
|
let pi_module = temp.path().join("pi-connector.mjs");
|
|
std::fs::write(&pi_module, &pi_script).unwrap();
|
|
run_node_module_smoke(
|
|
&format!(
|
|
r#"
|
|
const mod = await import('file://{module_path}');
|
|
const handlers = new Map();
|
|
mod.default({{ on: (name, callback) => handlers.set(name, callback) }});
|
|
if (!handlers.has('tool_call')) throw new Error('Pi tool_call handler missing');
|
|
if (!handlers.has('tool_execution_end')) throw new Error('Pi tool_execution_end handler missing');
|
|
if (!handlers.has('message_end')) throw new Error('Pi message_end handler missing');
|
|
if (!handlers.has('input')) throw new Error('Pi input handler missing');
|
|
if (!handlers.has('agent_settled')) throw new Error('Pi agent_settled handler missing');
|
|
await handlers.get('input')(
|
|
{{ type: 'input', text: 'Pi runtime prompt', source: 'interactive' }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_message' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('before_agent_start')(
|
|
{{ type: 'before_agent_start', prompt: 'Pi runtime prompt' }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_message' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('message_end')(
|
|
{{ type: 'message_end', message: {{ role: 'assistant', content: [{{ type: 'text', text: 'Pi runtime assistant response' }}], stopReason: 'stop' }} }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_message' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('agent_end')(
|
|
{{ type: 'agent_end', messages: [{{ role: 'assistant', content: [{{ type: 'text', text: 'Pi runtime assistant response' }}], stopReason: 'stop' }}] }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_message' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('agent_settled')(
|
|
{{ type: 'agent_settled' }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_message' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('tool_call')(
|
|
{{ type: 'tool_call', toolName: 'bash', toolCallId: 'secret-call', input: {{ command: 'secret' }} }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_runtime' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('tool_execution_end')(
|
|
{{ type: 'tool_execution_end', toolName: 'bash', toolCallId: 'secret-call', result: 'secret-output', isError: true }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_failed' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await handlers.get('agent_settled')(
|
|
{{ type: 'agent_settled' }},
|
|
{{ sessionManager: {{ getSessionId: () => 'sess_pi_done' }}, cwd: '/tmp/pi-project' }}
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
"#,
|
|
module_path = pi_module.display()
|
|
),
|
|
&capture_path,
|
|
);
|
|
let pi_capture = wait_for_capture(&capture_path, "--source pi");
|
|
assert!(pi_capture.contains("--event-type auto"));
|
|
assert!(pi_capture.contains("\"type\":\"tool_call\""));
|
|
assert!(pi_capture.contains("\"session_id\":\"sess_pi_runtime\""));
|
|
assert!(pi_capture.contains("\"is_error\":true"));
|
|
assert!(pi_capture.contains("\"type\":\"agent_settled\""));
|
|
assert!(pi_capture.contains("\"type\":\"message_end\""));
|
|
assert!(pi_capture.contains("\"type\":\"input\""));
|
|
assert!(pi_capture.contains("Pi runtime prompt"));
|
|
assert!(pi_capture.contains("Pi runtime assistant response"));
|
|
assert!(pi_capture.contains("\"turn_id\":"));
|
|
assert!(pi_capture.contains("secret-output"));
|
|
|
|
let opencode_status = connections::repair_source(&paths, AgentSource::Opencode).unwrap();
|
|
let opencode_plugin = fake_agent_home
|
|
.join(".config")
|
|
.join("opencode")
|
|
.join("plugins")
|
|
.join("agent-pet-companion.js");
|
|
let opencode_script = std::fs::read_to_string(&opencode_plugin).unwrap();
|
|
assert!(opencode_script.contains("export const AgentPetCompanion"));
|
|
assert!(opencode_script.contains("event: async ({ event })"));
|
|
assert!(opencode_script.contains("\"tool.execute.before\""));
|
|
assert!(opencode_script.contains("\"--event-type\", \"auto\""));
|
|
assert!(opencode_script.contains("output?.args"));
|
|
assert!(opencode_script.contains("\"permission.ask\""));
|
|
assert!(opencode_script.contains("\"command.execute.before\""));
|
|
assert!(opencode_script.contains("type: \"connector.probe\""));
|
|
assert!(!opencode_script.contains("session.done"));
|
|
assert!(!opencode_script.contains("tool.execute.failed"));
|
|
assert!(opencode_status
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "Plugin" && item.status == CheckStatus::Ok));
|
|
assert!(opencode_status
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "OpenCode Server" && item.status == CheckStatus::NotRequired));
|
|
assert!(opencode_status.items.iter().any(|item| {
|
|
item.name == "Plugin 运行时"
|
|
&& item.status == CheckStatus::Unverified
|
|
&& item.detail.contains("外部事件 CLI 覆盖")
|
|
}));
|
|
assert!(!fake_agent_home
|
|
.join(".config")
|
|
.join("opencode")
|
|
.join("plugins")
|
|
.join("server-check.json")
|
|
.exists());
|
|
let opencode_module = temp.path().join("opencode-connector.mjs");
|
|
std::fs::write(&opencode_module, &opencode_script).unwrap();
|
|
run_node_module_smoke(
|
|
&format!(
|
|
r#"
|
|
const mod = await import('file://{module_path}');
|
|
const plugin = await mod.AgentPetCompanion({{
|
|
project: 'demo',
|
|
directory: '/tmp/opencode-project',
|
|
worktree: '/tmp/opencode-worktree'
|
|
}});
|
|
if (!plugin['tool.execute.before']) throw new Error('OpenCode tool.execute.before handler missing');
|
|
if (!plugin['chat.message']) throw new Error('OpenCode chat.message handler missing');
|
|
await plugin['chat.message'](
|
|
{{ sessionID: 'sess_opencode_runtime' }},
|
|
{{ parts: [{{ type: 'text', text: 'OpenCode runtime prompt' }}] }}
|
|
);
|
|
await plugin.event({{ event: {{
|
|
type: 'message.updated',
|
|
properties: {{ info: {{ id: 'opencode-message', sessionID: 'sess_opencode_runtime', role: 'assistant', time: {{ created: 1, completed: 2 }} }} }}
|
|
}} }});
|
|
await plugin.event({{ event: {{
|
|
type: 'message.part.updated',
|
|
properties: {{ part: {{ id: 'opencode-part', messageID: 'opencode-message', sessionID: 'sess_opencode_runtime', type: 'text', text: 'OpenCode runtime assistant response' }} }}
|
|
}} }});
|
|
await plugin.event({{ event: {{
|
|
type: 'session.status',
|
|
properties: {{ sessionID: 'sess_opencode_runtime', status: {{ type: 'idle' }} }}
|
|
}} }});
|
|
await plugin.event({{ event: {{
|
|
type: 'session.idle',
|
|
properties: {{ sessionID: 'sess_opencode_runtime' }}
|
|
}} }});
|
|
await plugin.event({{ event: {{
|
|
type: 'session.idle',
|
|
properties: {{ sessionID: 'sess_opencode_idle_fallback_runtime' }}
|
|
}} }});
|
|
await plugin.event({{ event: {{
|
|
type: 'session.deleted',
|
|
properties: {{ info: {{ id: 'sess_opencode_deleted_runtime' }} }}
|
|
}} }});
|
|
await plugin['tool.execute.before'](
|
|
{{ tool: 'bash', sessionID: 'sess_opencode_runtime', callID: 'secret-call-id' }},
|
|
{{ args: {{ command: 'TOKEN=secret-command' }} }}
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
"#,
|
|
module_path = opencode_module.display()
|
|
),
|
|
&capture_path,
|
|
);
|
|
let opencode_capture = wait_for_capture(&capture_path, "--source opencode");
|
|
assert!(opencode_capture.contains("--event-type auto"));
|
|
assert!(opencode_capture.contains("\"type\":\"tool.execute.before\""));
|
|
assert!(opencode_capture.contains("\"sessionID\":\"sess_opencode_runtime\""));
|
|
assert!(opencode_capture.contains("\"type\":\"message.user\""));
|
|
assert!(opencode_capture.contains("OpenCode runtime prompt"));
|
|
assert!(opencode_capture.contains("\"type\":\"message.assistant\""));
|
|
assert!(opencode_capture.contains("OpenCode runtime assistant response"));
|
|
assert_eq!(
|
|
opencode_capture
|
|
.matches("\"type\":\"session.status\"")
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
opencode_capture
|
|
.matches("\"type\":\"session.idle\"")
|
|
.count(),
|
|
1,
|
|
"status(idle) + session.idle must be one edge while idle-only hosts still work"
|
|
);
|
|
assert!(opencode_capture.contains("sess_opencode_idle_fallback_runtime"));
|
|
assert!(opencode_capture.contains("\"type\":\"session.deleted\""));
|
|
assert!(opencode_capture.contains("\"sessionID\":\"sess_opencode_deleted_runtime\""));
|
|
assert!(opencode_capture.contains("secret-command"));
|
|
|
|
let codex_uninstall = connections::uninstall_source(&paths, AgentSource::Codex).unwrap();
|
|
let codex_marketplace_after = std::fs::read_to_string(&codex_marketplace).unwrap();
|
|
assert!(!codex_plugin.exists());
|
|
assert!(!codex_marketplace_after.contains("agent-pet-companion"));
|
|
assert!(codex_uninstall
|
|
.items
|
|
.iter()
|
|
.any(|item| item.name == "插件源" && item.status != CheckStatus::Ok));
|
|
|
|
connections::uninstall_source(&paths, AgentSource::ClaudeCode).unwrap();
|
|
let claude_settings_after = std::fs::read_to_string(&claude_settings).unwrap();
|
|
assert!(!claude_settings_after.contains("agent hook --source claude_code"));
|
|
|
|
connections::uninstall_source(&paths, AgentSource::Pi).unwrap();
|
|
assert!(!pi_extension.exists());
|
|
|
|
connections::uninstall_source(&paths, AgentSource::Opencode).unwrap();
|
|
assert!(!opencode_plugin.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn codex_repair_rejects_symlinked_maker_parents_and_targets() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
write_fake_cli(&fake_cli);
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", fake_cli.as_os_str());
|
|
let _app_server_cmd = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "");
|
|
let _disable_app_server_auto = EnvVarGuard::set("APC_DISABLE_CODEX_APP_SERVER_AUTO", "1");
|
|
|
|
for target_kind in ["parent", "file", "install-result"] {
|
|
let fake_agent_home = temp.path().join(format!("agent-home-{target_kind}"));
|
|
let _agent_home = EnvVarGuard::set("APC_AGENT_CONFIG_HOME", &fake_agent_home);
|
|
let paths = AppPaths::new(temp.path().join(format!("app-home-{target_kind}")));
|
|
paths.ensure().unwrap();
|
|
connections::repair_source(&paths, AgentSource::Codex).unwrap();
|
|
|
|
let plugin_root = fake_agent_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion");
|
|
let maker_root = plugin_root.join("skills").join("agent-pet-maker");
|
|
|
|
let outside = temp.path().join(format!("outside-{target_kind}"));
|
|
std::fs::create_dir_all(&outside).unwrap();
|
|
let sentinel = outside.join("sentinel.txt");
|
|
let sentinel_content = if target_kind == "file" {
|
|
std::fs::read_to_string(
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../skills/agent-pet-maker/SKILL.md"),
|
|
)
|
|
.unwrap()
|
|
} else {
|
|
format!("do not overwrite {target_kind}")
|
|
};
|
|
std::fs::write(&sentinel, &sentinel_content).unwrap();
|
|
|
|
match target_kind {
|
|
"parent" => {
|
|
std::fs::remove_dir_all(maker_root.join("scripts")).unwrap();
|
|
std::os::unix::fs::symlink(&outside, maker_root.join("scripts")).unwrap();
|
|
}
|
|
"file" => {
|
|
std::fs::remove_file(maker_root.join("SKILL.md")).unwrap();
|
|
std::os::unix::fs::symlink(&sentinel, maker_root.join("SKILL.md")).unwrap();
|
|
}
|
|
"install-result" => {
|
|
std::fs::remove_file(plugin_root.join("codex-install-result.json")).unwrap();
|
|
std::os::unix::fs::symlink(
|
|
&sentinel,
|
|
plugin_root.join("codex-install-result.json"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
if target_kind != "install-result" {
|
|
let codex_status = connections::check_all_light(&paths)
|
|
.into_iter()
|
|
.find(|status| status.source == AgentSource::Codex)
|
|
.unwrap();
|
|
let maker_status = codex_status
|
|
.items
|
|
.iter()
|
|
.find(|item| item.name.starts_with("Agent Pet Maker Skill"))
|
|
.unwrap();
|
|
assert_eq!(maker_status.status, CheckStatus::NeedsFix);
|
|
assert!(maker_status.name.ends_with("冲突"));
|
|
}
|
|
|
|
let error = connections::repair_source(&paths, AgentSource::Codex).unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("符号链接"),
|
|
"unexpected repair error for {target_kind}: {error}"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&sentinel).unwrap(),
|
|
sentinel_content
|
|
);
|
|
assert!(
|
|
!outside.join("petpack_workspace.py").exists(),
|
|
"repair escaped through the symlinked {target_kind}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn codex_repair_returns_plugin_install_failure_without_hiding_it() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_home = temp.path().join("home");
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
let bin_dir = temp.path().join("bin");
|
|
std::fs::create_dir_all(&bin_dir).unwrap();
|
|
let codex = bin_dir.join("codex");
|
|
std::fs::write(
|
|
&codex,
|
|
r#"#!/bin/sh
|
|
if [ "$1" = "plugin" ] && [ "$2" = "add" ]; then
|
|
printf '%s\n' '{"error":"install failed"}' >&2
|
|
exit 42
|
|
fi
|
|
if [ "$1" = "plugin" ] && [ "$2" = "list" ] && [ "$3" = "--json" ]; then
|
|
printf '%s\n' '{"installed":[]}'
|
|
exit 0
|
|
fi
|
|
exit 0
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let mut permissions = std::fs::metadata(&codex).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&codex, permissions).unwrap();
|
|
write_fake_cli(&fake_cli);
|
|
|
|
let _home = EnvVarGuard::set("HOME", fake_home.as_os_str());
|
|
// This test deliberately exercises the real Codex installation branch inside
|
|
// a temporary HOME. Do not let a caller's isolation override turn that branch
|
|
// into the APC_AGENT_CONFIG_HOME dry-run path.
|
|
let _agent_config_home = EnvVarGuard::remove("APC_AGENT_CONFIG_HOME");
|
|
let _codex_cli = EnvVarGuard::set("APC_CODEX_CLI_PATH", codex.as_os_str());
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", fake_cli.as_os_str());
|
|
let _path = EnvVarGuard::set(
|
|
"PATH",
|
|
format!(
|
|
"{}:{}",
|
|
bin_dir.display(),
|
|
std::env::var("PATH").unwrap_or_default()
|
|
),
|
|
);
|
|
let _disable_app_server_auto = EnvVarGuard::set("APC_DISABLE_CODEX_APP_SERVER_AUTO", "1");
|
|
let _app_server_cmd = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "");
|
|
let paths = AppPaths::new(temp.path().join("apc-home"));
|
|
paths.ensure().unwrap();
|
|
|
|
let error = connections::repair_source(&paths, AgentSource::Codex).unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("codex plugin add 未成功完成"),
|
|
"unexpected error: {error}"
|
|
);
|
|
|
|
let result_path = fake_home
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("plugins")
|
|
.join("agent-pet-companion")
|
|
.join("codex-install-result.json");
|
|
let install_result: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(result_path).unwrap()).unwrap();
|
|
assert_eq!(install_result["status"], "failed");
|
|
assert_eq!(install_result["phase"], "plugin_add");
|
|
assert_eq!(install_result["code"], 42);
|
|
assert_eq!(install_result["timed_out"], false);
|
|
assert!(install_result.get("stdout").is_none());
|
|
assert!(install_result.get("stderr").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn connection_check_reports_event_channel_when_socket_is_reachable() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
write_fake_cli(&fake_cli);
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", fake_cli.as_os_str());
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
paths.ensure().unwrap();
|
|
let _listener = UnixListener::bind(&paths.socket_path).unwrap();
|
|
|
|
let status = connections::check_source(&paths, AgentSource::Pi);
|
|
let event_channel = status
|
|
.items
|
|
.iter()
|
|
.find(|item| item.name == "事件回传")
|
|
.expect("event channel check should be present");
|
|
|
|
assert_eq!(event_channel.status, CheckStatus::Ok);
|
|
assert!(event_channel.detail.contains("socket 已连接"));
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_and_inexact_manual_check_do_not_spawn_codex_app_server_probe() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_home = temp.path().join("user-home");
|
|
std::fs::create_dir_all(&fake_home).unwrap();
|
|
let _home = EnvVarGuard::set("HOME", fake_home.as_os_str());
|
|
let _agent_config_home = EnvVarGuard::remove("APC_AGENT_CONFIG_HOME");
|
|
let app_server_marker = temp.path().join("app-server-spawned");
|
|
let codex_marker = temp.path().join("codex-cli-spawned");
|
|
let script = temp.path().join("marker_app_server.sh");
|
|
std::fs::write(
|
|
&script,
|
|
format!(
|
|
r#"#!/bin/sh
|
|
touch '{}'
|
|
while IFS= read -r request; do
|
|
case "$request" in
|
|
*initialize*)
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"serverInfo":{{"name":"marker-app-server"}}}}}}'
|
|
;;
|
|
esac
|
|
done
|
|
"#,
|
|
app_server_marker.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
let mut permissions = std::fs::metadata(&script).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&script, permissions).unwrap();
|
|
|
|
let bin_dir = temp.path().join("bin");
|
|
std::fs::create_dir_all(&bin_dir).unwrap();
|
|
let codex_script = bin_dir.join("codex");
|
|
std::fs::write(
|
|
&codex_script,
|
|
format!(
|
|
r#"#!/bin/sh
|
|
touch '{}'
|
|
printf '%s\n' '{{"installed":[]}}'
|
|
"#,
|
|
codex_marker.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
let mut codex_permissions = std::fs::metadata(&codex_script).unwrap().permissions();
|
|
codex_permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&codex_script, codex_permissions).unwrap();
|
|
let _codex_cli = EnvVarGuard::set("APC_CODEX_CLI_PATH", codex_script.as_os_str());
|
|
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", script.as_os_str());
|
|
let _path = EnvVarGuard::set(
|
|
"PATH",
|
|
format!(
|
|
"{}:{}",
|
|
bin_dir.display(),
|
|
std::env::var("PATH").unwrap_or_default()
|
|
),
|
|
);
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
!app_server_marker.exists(),
|
|
"state.snapshot spawned Codex App Server"
|
|
);
|
|
assert!(!codex_marker.exists(), "state.snapshot invoked codex CLI");
|
|
let codex_status = snapshot["connections"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|status| status["source"] == "codex")
|
|
.unwrap();
|
|
let app_server_item = codex_status["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|item| item["name"] == "Codex App Server")
|
|
.unwrap();
|
|
assert_eq!(app_server_item["status"], "unverified");
|
|
assert!(app_server_item["detail"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("未启动 Agent 宿主"));
|
|
|
|
let checked = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "connections.check".to_string(),
|
|
params: json!({ "source": "codex" }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
!app_server_marker.exists(),
|
|
"manual connection check loaded App Server before the static connector was exact"
|
|
);
|
|
assert!(
|
|
codex_marker.exists(),
|
|
"manual connection check did not invoke codex CLI"
|
|
);
|
|
assert!(checked["items"].as_array().unwrap().iter().any(|item| {
|
|
item["name"] == "Codex App Server"
|
|
&& item["status"] == "unverified"
|
|
&& item["detail"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("未启动 Agent 宿主")
|
|
}));
|
|
|
|
let cached_snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let cached_codex_status = cached_snapshot["connections"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|status| status["source"] == "codex")
|
|
.unwrap();
|
|
assert_eq!(cached_codex_status["check_mode"], "light");
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_preserves_cached_runtime_connection_status_when_light_check_is_clean() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let fake_agent_home = temp.path().join("agent-home");
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
write_fake_cli(&fake_cli);
|
|
let bin_dir = temp.path().join("bin");
|
|
std::fs::create_dir_all(&bin_dir).unwrap();
|
|
let pi_command = bin_dir.join("pi");
|
|
std::fs::write(&pi_command, "#!/bin/sh\nexit 0\n").unwrap();
|
|
let mut pi_permissions = std::fs::metadata(&pi_command).unwrap().permissions();
|
|
pi_permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&pi_command, pi_permissions).unwrap();
|
|
|
|
let _agent_home = EnvVarGuard::set("APC_AGENT_CONFIG_HOME", fake_agent_home.as_os_str());
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", fake_cli.as_os_str());
|
|
let _path = EnvVarGuard::set(
|
|
"PATH",
|
|
format!(
|
|
"{}:{}",
|
|
bin_dir.display(),
|
|
std::env::var("PATH").unwrap_or_default()
|
|
),
|
|
);
|
|
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
let _listener = UnixListener::bind(&state.paths.socket_path).unwrap();
|
|
|
|
let checked = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "connections.repair".to_string(),
|
|
params: json!({ "source": "pi" }),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(checked["check_mode"], "runtime");
|
|
assert!(checked["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|item| { item["status"] != "needs_fix" && item["status"] != "missing" }));
|
|
|
|
let snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let pi_status = snapshot["connections"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|status| status["source"] == "pi")
|
|
.unwrap();
|
|
assert_eq!(pi_status["check_mode"], "runtime");
|
|
assert!(pi_status["items"].as_array().unwrap().iter().any(|item| {
|
|
item["name"] == "PetCore 通道自检"
|
|
&& item["status"] == "unverified"
|
|
&& item["detail"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("跳过自动写入自检")
|
|
}));
|
|
|
|
let extension = fake_agent_home.join(".pi/agent/extensions/agent-pet-companion.ts");
|
|
let mut changed = std::fs::read_to_string(&extension).unwrap();
|
|
changed.push_str("\n// connector changed after the cached runtime check\n");
|
|
std::fs::write(&extension, changed).unwrap();
|
|
let stale_snapshot = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "state.snapshot".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let stale_pi_status = stale_snapshot["connections"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|status| status["source"] == "pi")
|
|
.unwrap();
|
|
assert_eq!(stale_pi_status["check_mode"], "light");
|
|
assert_eq!(stale_pi_status["verification"]["status"], "action_required");
|
|
}
|
|
|
|
#[test]
|
|
fn codex_app_server_probe_uses_configured_stdio_command() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let script = temp.path().join("fake_app_server.sh");
|
|
let fake_cli = temp.path().join("petcore-cli");
|
|
write_fake_app_server_script(&script, "thread_fake_pet_studio");
|
|
write_fake_cli(&fake_cli);
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", script.as_os_str());
|
|
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", fake_cli.as_os_str());
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let probe = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "codex.app_server.probe".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(probe["initialized"], true);
|
|
assert_eq!(probe["mode"], "configured");
|
|
assert_eq!(
|
|
probe["response"]["result"]["serverInfo"]["name"],
|
|
"fake-codex-app-server"
|
|
);
|
|
assert_eq!(
|
|
probe["response"]["result"]["serverInfo"]["petcoreCli"],
|
|
fake_cli.display().to_string()
|
|
);
|
|
|
|
let thread = petcore::app_server::start_pet_studio_thread(
|
|
&state.paths,
|
|
"job_fake",
|
|
&petcore_types::GenerationForm {
|
|
description: "测试".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: vec![],
|
|
},
|
|
);
|
|
assert_eq!(thread["started"], true);
|
|
assert_eq!(thread["thread_id"], "thread_fake_pet_studio");
|
|
|
|
let session = petcore::app_server::run_pet_studio_session(
|
|
&state.paths,
|
|
"job_fake_turn",
|
|
&petcore_types::GenerationForm {
|
|
description: "测试".to_string(),
|
|
style: "半写实".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
reference_images: vec![],
|
|
},
|
|
);
|
|
assert_eq!(session["completed"], true);
|
|
assert_eq!(session["turn_id"], "turn_fake_pet_studio");
|
|
assert_eq!(session["ai_brief"]["name"], "AI 云袖");
|
|
assert_eq!(session["ai_brief"]["states"].as_array().unwrap().len(), 9);
|
|
assert!(session["ai_brief_warnings"].as_array().unwrap().len() >= 5);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_active_session_is_enriched_from_explicit_thread_read() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let script = temp.path().join("thread_display_app_server.sh");
|
|
std::fs::write(
|
|
&script,
|
|
r#"#!/bin/sh
|
|
while IFS= read -r request; do
|
|
case "$request" in
|
|
*initialize*)
|
|
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"fake-thread-display"}}}'
|
|
;;
|
|
*thread/read*)
|
|
printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"thread":{"name":"Current Codex task","turns":[{"items":[{"type":"userMessage","id":"u1","content":[{"type":"text","text":"Show this conversation"}]},{"type":"agentMessage","id":"a1","text":"Latest agent response"}]}]}}}'
|
|
;;
|
|
esac
|
|
done
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", script.as_os_str());
|
|
let state = CoreState::new(AppPaths::new(temp.path().join("home")));
|
|
state.ensure_ready().unwrap();
|
|
let request = |method: &str, params| RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: method.to_string(),
|
|
params,
|
|
};
|
|
|
|
handle_request(
|
|
&state,
|
|
request(
|
|
"agent.ingest",
|
|
json!({
|
|
"id": "codex-thread-display-event",
|
|
"source": "codex",
|
|
"session_id": "019f5a6f-0c52-75e1-b652-004d4487c4ae",
|
|
"event_type": "tool",
|
|
"payload": {
|
|
"schema_version": "apc.agent-event.v1",
|
|
"source_event": "PreToolUse",
|
|
"session_active": true,
|
|
"session_open": null,
|
|
"session_surface": "chatgpt_app"
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
let snapshot = handle_request(&state, request("state.snapshot", json!({}))).unwrap();
|
|
let active = &snapshot["active_agent_state"];
|
|
if active["overlay_display"]["navigation"]["session_open"] == true {
|
|
assert_eq!(
|
|
active["overlay_display"]["navigation"]["capability"],
|
|
"exact_session"
|
|
);
|
|
assert!(active["session_id"].as_str().unwrap().starts_with("ses-"));
|
|
assert_eq!(
|
|
active["overlay_display"]["navigation"]["surface"],
|
|
"chatgpt_app"
|
|
);
|
|
assert_eq!(
|
|
active["overlay_display"]["navigation"]["routable_session_id"],
|
|
"019f5a6f-0c52-75e1-b652-004d4487c4ae"
|
|
);
|
|
assert_eq!(active["session_title"], "Current Codex task");
|
|
assert_eq!(
|
|
active["session_user_message"],
|
|
json!({"role": "user", "content": "Show this conversation"})
|
|
);
|
|
assert_eq!(
|
|
active["session_message"],
|
|
json!({"role": "assistant", "content": "Latest agent response"})
|
|
);
|
|
assert!(active["event"].get("payload_json").is_none());
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"thread display enrichment timed out: {active}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn codex_app_server_probe_reports_structured_stdio_errors() {
|
|
let _env_lock = lock_env();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let script = temp.path().join("failing_app_server.sh");
|
|
let mut file = std::fs::File::create(&script).unwrap();
|
|
writeln!(
|
|
file,
|
|
r#"#!/bin/sh
|
|
while IFS= read -r request; do
|
|
case "$request" in
|
|
*initialize*)
|
|
printf '%s\n' 'fatal initialize diagnostic' >&2
|
|
printf '%s\n' '{{"jsonrpc":"2.0","id":1,"error":{{"code":-32000,"message":"initialize boom"}}}}'
|
|
;;
|
|
esac
|
|
done
|
|
"#
|
|
)
|
|
.unwrap();
|
|
let mut permissions = std::fs::metadata(&script).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&script, permissions).unwrap();
|
|
|
|
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", script.as_os_str());
|
|
let paths = AppPaths::new(temp.path().join("home"));
|
|
let state = CoreState::new(paths);
|
|
state.ensure_ready().unwrap();
|
|
|
|
let probe = handle_request(
|
|
&state,
|
|
RpcRequest {
|
|
jsonrpc: Some("2.0".to_string()),
|
|
id: Some(json!("test")),
|
|
method: "codex.app_server.probe".to_string(),
|
|
params: json!({}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(probe["initialized"], false);
|
|
assert_eq!(probe["mode"], "configured");
|
|
assert_eq!(probe["error_info"]["kind"], "server_error");
|
|
assert_eq!(probe["error_info"]["stage"], "initialize");
|
|
assert_eq!(probe["error_info"]["method"], "initialize");
|
|
assert!(probe["error"].as_str().unwrap().contains("initialize boom"));
|
|
assert!(probe["error_info"]["stderr_tail"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|line| line.as_str() == Some("fatal initialize diagnostic")));
|
|
}
|