Compare commits
3 Commits
2790a35acf
...
00755cf1b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 00755cf1b8 | |||
| 31d9b9b2b7 | |||
| d18de45293 |
|
|
@ -1,2 +1,3 @@
|
||||||
[memory]
|
[memory]
|
||||||
extract_threshold = 10000
|
extract_threshold = 10000
|
||||||
|
consolidation_threshold_files = 10
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,11 @@ pub(crate) struct ReasoningConfig {
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub(crate) enum InputItem {
|
pub(crate) enum InputItem {
|
||||||
/// 会話メッセージ。user / assistant / system のいずれか。
|
/// 会話メッセージ。user / assistant / developer のいずれか。
|
||||||
|
/// `Role::System` items は `developer` として投影する(ChatGPT
|
||||||
|
/// backend が `role: "system"` を拒否するため。Codex CLI も
|
||||||
|
/// system 相当の挿入には DeveloperInstructions = `role: "developer"`
|
||||||
|
/// を使う)。
|
||||||
Message {
|
Message {
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
content: Vec<InputContent>,
|
content: Vec<InputContent>,
|
||||||
|
|
@ -104,7 +108,7 @@ pub(crate) enum InputItem {
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub(crate) enum InputContent {
|
pub(crate) enum InputContent {
|
||||||
/// user / system 側のテキスト
|
/// user / developer 側のテキスト
|
||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
/// assistant 側のテキスト
|
/// assistant 側のテキスト
|
||||||
OutputText { text: String },
|
OutputText { text: String },
|
||||||
|
|
@ -230,7 +234,7 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
||||||
match role {
|
match role {
|
||||||
Role::User => ("user", |t| InputContent::InputText { text: t }),
|
Role::User => ("user", |t| InputContent::InputText { text: t }),
|
||||||
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
|
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
|
||||||
Role::System => ("system", |t| InputContent::InputText { text: t }),
|
Role::System => ("developer", |t| InputContent::InputText { text: t }),
|
||||||
};
|
};
|
||||||
let parts: Vec<InputContent> = content
|
let parts: Vec<InputContent> = content
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -387,6 +391,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_role_item_is_projected_as_developer() {
|
||||||
|
// ChatGPT backend (codex-oauth) は input[] の `role: "system"` を
|
||||||
|
// "System messages are not allowed" で 400 拒否する。in-conversation
|
||||||
|
// な system note (notify / fs_view auto-read / compaction summary) は
|
||||||
|
// `role: "developer"` として投影し、両 backend で受理されるようにする。
|
||||||
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
let req = Request::new()
|
||||||
|
.user("hi")
|
||||||
|
.item(Item::system_message("[notify] hello"));
|
||||||
|
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||||
|
match &body.input[1] {
|
||||||
|
InputItem::Message { role, content } => {
|
||||||
|
assert_eq!(*role, "developer");
|
||||||
|
assert!(
|
||||||
|
matches!(&content[0], InputContent::InputText { text } if text == "[notify] hello"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => panic!("expected message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assistant_message_uses_output_text() {
|
fn assistant_message_uses_output_text() {
|
||||||
let scheme = OpenAIResponsesScheme::new();
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,9 @@ memory = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
session-store = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1333,7 +1333,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let Some(memory_cfg) = self.manifest.memory.clone() else {
|
let Some(memory_cfg) = self.manifest.memory.clone() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let Some(threshold) = memory_cfg.extract_threshold else {
|
// `Some(0)` means disabled, same as `None`. Otherwise the
|
||||||
|
// `tokens_since >= 0` comparison would fire on every post-run.
|
||||||
|
let Some(threshold) = memory_cfg.extract_threshold.filter(|n| *n > 0) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1538,8 +1540,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let Some(memory_cfg) = self.manifest.memory.clone() else {
|
let Some(memory_cfg) = self.manifest.memory.clone() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let files_threshold = memory_cfg.consolidation_threshold_files;
|
// `Some(0)` collapses to `None` — staging count / bytes always
|
||||||
let bytes_threshold = memory_cfg.consolidation_threshold_bytes;
|
// satisfies `>= 0`, which would fire Phase 2 on every post-run.
|
||||||
|
// Treating zero as disabled lines up with `extract_threshold` and
|
||||||
|
// matches the "no threshold ⇒ Phase 2 off" invariant in the
|
||||||
|
// ticket's §Trigger.
|
||||||
|
let files_threshold = memory_cfg.consolidation_threshold_files.filter(|n| *n > 0);
|
||||||
|
let bytes_threshold = memory_cfg.consolidation_threshold_bytes.filter(|n| *n > 0);
|
||||||
if files_threshold.is_none() && bytes_threshold.is_none() {
|
if files_threshold.is_none() && bytes_threshold.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,49 @@ async fn compact_resets_extract_pointer_so_phase1_can_fire_again() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `extract_threshold = 0` is treated as "disabled" — without this, a
|
||||||
|
/// raw `>=` comparison against `tokens_since` would fire Phase 1 on
|
||||||
|
/// every post-run regardless of activity. Mirrors the Phase 2
|
||||||
|
/// zero-threshold convention so users have a single way to opt out
|
||||||
|
/// without removing the `[memory]` section.
|
||||||
|
const EXTRACT_THRESHOLD_ZERO_MANIFEST: &str = r#"
|
||||||
|
[pod]
|
||||||
|
name = "test-pod"
|
||||||
|
pwd = "./"
|
||||||
|
|
||||||
|
[model]
|
||||||
|
scheme = "anthropic"
|
||||||
|
model_id = "test-model"
|
||||||
|
|
||||||
|
[worker]
|
||||||
|
max_tokens = 100
|
||||||
|
|
||||||
|
[memory]
|
||||||
|
extract_threshold = 0
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "./"
|
||||||
|
permission = "write"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn extract_threshold_zero_is_disabled() {
|
||||||
|
// Mock provides exactly one response — the first run. If Phase 1
|
||||||
|
// were treated as "fire on any change" because of `tokens_since >= 0`,
|
||||||
|
// it would call into the extract worker and exhaust the mock.
|
||||||
|
let client = MockClient::new(vec![text_events_with_usage("hi", 1000)]);
|
||||||
|
let mut pod = make_pod_with_manifest(EXTRACT_THRESHOLD_ZERO_MANIFEST, client).await;
|
||||||
|
|
||||||
|
pod.run_text("first").await.unwrap();
|
||||||
|
pod.try_post_run_extract()
|
||||||
|
.await
|
||||||
|
.expect("extract_threshold=0 must skip silently, not fail");
|
||||||
|
assert!(
|
||||||
|
pod.extract_pointer().is_none(),
|
||||||
|
"no extract should have run — pointer must remain None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_run_compact_failure_broadcasts_start_and_failed() {
|
async fn post_run_compact_failure_broadcasts_start_and_failed() {
|
||||||
// Only the first run has a response. Compaction will run the
|
// Only the first run has a response. Compaction will run the
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,26 @@ target = "./"
|
||||||
permission = "write"
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const ZERO_THRESHOLDS_TOML: &str = r#"
|
||||||
|
[pod]
|
||||||
|
name = "test-pod"
|
||||||
|
|
||||||
|
[model]
|
||||||
|
scheme = "anthropic"
|
||||||
|
model_id = "test-model"
|
||||||
|
|
||||||
|
[worker]
|
||||||
|
max_tokens = 100
|
||||||
|
|
||||||
|
[memory]
|
||||||
|
consolidation_threshold_files = 0
|
||||||
|
consolidation_threshold_bytes = 0
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "./"
|
||||||
|
permission = "write"
|
||||||
|
"#;
|
||||||
|
|
||||||
async fn make_pod_with(
|
async fn make_pod_with(
|
||||||
manifest_toml: &str,
|
manifest_toml: &str,
|
||||||
pwd: std::path::PathBuf,
|
pwd: std::path::PathBuf,
|
||||||
|
|
@ -192,6 +212,30 @@ async fn no_thresholds_is_a_noop() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn zero_thresholds_treated_as_disabled() {
|
||||||
|
// Without the `Some(0) → None` collapse, `total_files >= 0` and
|
||||||
|
// `total_bytes >= 0` would always evaluate true and Phase 2 would
|
||||||
|
// fire on every post-run with any staging activity.
|
||||||
|
let pwd = tempfile::tempdir().unwrap();
|
||||||
|
let layout = WorkspaceLayout::new(pwd.path().to_path_buf());
|
||||||
|
write_n_staging(&layout, 5);
|
||||||
|
|
||||||
|
let client = MockClient::new(vec![]);
|
||||||
|
let mut pod = make_pod_with(ZERO_THRESHOLDS_TOML, pwd.path().to_path_buf(), client).await;
|
||||||
|
pod.try_post_run_consolidate()
|
||||||
|
.await
|
||||||
|
.expect("zero thresholds must collapse to disabled, not fire on every staging entry");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
memory::consolidate::list_staging_entries(&layout).len(),
|
||||||
|
5,
|
||||||
|
"staging must be untouched when both thresholds are zero"
|
||||||
|
);
|
||||||
|
let lock_path = layout.staging_dir().join(".consolidation.lock");
|
||||||
|
assert!(!lock_path.exists(), "no lock should be acquired");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn empty_staging_skips() {
|
async fn empty_staging_skips() {
|
||||||
let pwd = tempfile::tempdir().unwrap();
|
let pwd = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ hex = "0.4.3"
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "io-util"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user