Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions SYSTEM_PROMPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ Use `orx` as the source of truth for the experiment tree, runs, and logs. Use
normal repository tools for code and file inspection. Use this project id
(`{id}`) for every `orx` command that takes one.

**Always keep a task list.** Whenever a request takes more than one step — a
survey, an implementation, an experiment, a write-up — your first tool call is
your task-list tool (`TaskCreate` in Claude Code, `update_plan` in Codex,
`todowrite` in OpenCode) with every step listed. Mark each step in progress as
you begin it and completed the moment it is done (`TaskUpdate` in Claude
Code). The dashboard renders that list as a live checklist; without it the
user cannot see what you have finished or what you are doing now.

## Evidence and links in chat

Ground substantive claims about this project's code, files, artifacts, or
Expand Down
3 changes: 3 additions & 0 deletions src/local/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,9 @@ async fn spawn_client(spec: &SpawnSpec, auth_generation: u64) -> Result<Arc<Clau
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
crate::local::native_store::claude_secure_storage_config_dir(),
);
// Newer models ship without TaskCreate/TaskUpdate unless asked; the
// dashboard's progress checklist is built from those calls.
cmd.env("CLAUDE_CODE_ENABLE_TODO_TOOLS", "true");
// Stamp the launching session so `orx exp run` (a fresh subprocess the
// agent shells out) tags its run and can explicitly subscribe this chat.
// After prepare_env so it wins.
Expand Down
112 changes: 65 additions & 47 deletions src/local/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,19 +631,16 @@ fn assistant_parts(harness: &str) -> Vec<WirePart> {
"intro",
"I’ll keep this attached to one CPU / Apple-Silicon baseline, inspect the exact local pipeline, make the portability and SFT safeguards part of the experiment before launch, then stay on the streamed output through tokenizer training, base training and evaluation, SFT, and the final chat check.",
)];
if harness == "opencode" {
parts.push(tool_part(
"todo",
"todowrite",
json!({ "todos": [
{ "content": "Inspect and harden the CPU pipeline", "status": "completed", "priority": "high" },
{ "content": "Run base training, evaluation, and SFT", "status": "completed", "priority": "high" },
{ "content": "Confirm chat output and save results", "status": "completed", "priority": "medium" }
]}),
None,
Some("Track the nanochat pipeline"),
));
}
parts.push(task_list_part(
harness,
"todo",
"Track the nanochat pipeline",
&[
("Inspect and harden the CPU pipeline", "completed"),
("Run base training, evaluation, and SFT", "completed"),
("Confirm chat output and save results", "completed"),
],
));
let (read_name, read_input, edit_name, edit_input, shell_name) = match harness {
"claude-code" => (
"Read",
Expand Down Expand Up @@ -866,19 +863,16 @@ fn figure_assistant_parts(harness: &str) -> Vec<WirePart> {
"figure-intro",
"I’ll treat the recorded logs as the source of truth, keep base and SFT validation separate, and produce only the four quantitative SVGs requested. I’m first checking the report, complete per-step logs, and exact benchmark labels, then I’ll build the plots with one consistent Helvetica Neue academic style and visually inspect the rendered output.",
)];
if harness == "opencode" {
parts.push(tool_part(
"figure-todos",
"todowrite",
json!({ "todos": [
{ "content": "Parse base and SFT logs", "status": "completed", "priority": "high" },
{ "content": "Generate four SVG-only figures", "status": "completed", "priority": "high" },
{ "content": "Inspect and validate final artifacts", "status": "completed", "priority": "high" }
]}),
None,
Some("Track figure generation"),
));
}
parts.push(task_list_part(
harness,
"figure-todos",
"Track figure generation",
&[
("Parse base and SFT logs", "completed"),
("Generate four SVG-only figures", "completed"),
("Inspect and validate final artifacts", "completed"),
],
));
parts.push(tool_part(
"figure-read-report",
read_tool,
Expand Down Expand Up @@ -958,19 +952,16 @@ fn literature_assistant_parts(harness: &str) -> Vec<WirePart> {
"literature-intro",
"I’m using the literature-search workflow first, then I’ll inspect the nanochat experiment history so the recommendation is grounded in both scaling literature and the actual recorded runs.",
)];
if harness == "opencode" {
parts.push(tool_part(
"literature-todos",
"todowrite",
json!({ "todos": [
{ "content": "Inspect the recorded nanochat evidence", "status": "completed", "priority": "high" },
{ "content": "Review scaling and alignment literature", "status": "completed", "priority": "high" },
{ "content": "Recommend one controlled next experiment", "status": "completed", "priority": "high" }
]}),
None,
Some("Track the bottleneck diagnosis"),
));
}
parts.push(task_list_part(
harness,
"literature-todos",
"Track the bottleneck diagnosis",
&[
("Inspect the recorded nanochat evidence", "completed"),
("Review scaling and alignment literature", "completed"),
("Recommend one controlled next experiment", "completed"),
],
));
for (id, path, title) in [
(
"literature-skill",
Expand Down Expand Up @@ -1152,6 +1143,32 @@ fn literature_assistant_parts(harness: &str) -> Vec<WirePart> {
parts
}

/// The harness's own task-list tool call, in that harness's wire shape, so the
/// demo transcript shows the same step checklist a live session would.
fn task_list_part(harness: &str, id: &str, title: &str, steps: &[(&str, &str)]) -> WirePart {
let (tool, input) = match harness {
"claude-code" => (
"TodoWrite",
json!({ "todos": steps.iter().map(|(content, status)| {
json!({ "content": content, "status": status, "activeForm": content })
}).collect::<Vec<_>>() }),
),
"opencode" => (
"todowrite",
json!({ "todos": steps.iter().map(|(content, status)| {
json!({ "content": content, "status": status, "priority": "high" })
}).collect::<Vec<_>>() }),
),
_ => (
"update_plan",
json!({ "plan": steps.iter().map(|(step, status)| {
json!({ "step": step, "status": status })
}).collect::<Vec<_>>() }),
),
};
tool_part(id, tool, input, None, Some(title))
}

fn tool_part(
id: &str,
tool: &str,
Expand Down Expand Up @@ -1434,11 +1451,12 @@ mod tests {

#[test]
fn transcript_variants_are_one_turn_and_use_native_tool_names() {
for (harness, expected) in [
("claude-code", ["Read", "Edit", "Bash"]),
("codex", ["bash", "edit", "bash"]),
("opencode", ["read", "bash", "todowrite"]),
] {
let cases: [(&str, &[&str]); 3] = [
("claude-code", &["Read", "Edit", "Bash", "TodoWrite"]),
("codex", &["bash", "edit", "update_plan"]),
("opencode", &["read", "bash", "todowrite"]),
];
for (harness, expected) in cases {
let parts = assistant_parts(harness);
let encoded = serde_json::to_string(&parts).unwrap();
let decoded: Vec<WirePart> = serde_json::from_str(&encoded).unwrap();
Expand Down Expand Up @@ -1466,12 +1484,12 @@ mod tests {
.filter_map(|part| part.tool.as_deref())
.collect();
for tool in expected {
assert!(names.contains(&tool), "{harness} missing {tool}: {names:?}");
assert!(names.contains(tool), "{harness} missing {tool}: {names:?}");
}
let allowed: &[&str] = match harness {
"claude-code" => &["Read", "Edit", "Bash"],
"claude-code" => &["Read", "Edit", "Bash", "TodoWrite"],
"opencode" => &["read", "bash", "todowrite"],
_ => &["bash", "edit"],
_ => &["bash", "edit", "update_plan"],
};
assert!(names.iter().all(|name| allowed.contains(name)));
assert_eq!(parts.iter().filter(|part| part.kind == "prompt").count(), 0);
Expand Down
94 changes: 94 additions & 0 deletions src/local/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,11 @@ fn apply_notification(ctx: &mut TurnCtx, method: &str, params: &Value) -> Option
}
}
}
"turn/plan/updated" => {
if let Some(part) = plan_update_part(&ctx.assistant.parts, None, params) {
ctx.upsert_part(part);
}
}
"item/commandExecution/outputDelta" => {
let (Some(item_id), Some(delta)) = (
params.get("itemId").and_then(Value::as_str),
Expand Down Expand Up @@ -1131,6 +1136,40 @@ fn append_delta(ctx: &mut TurnCtx, params: &Value, make: impl FnOnce(String) ->
ctx.append_part_text(item_id, delta);
}

/// Codex's `update_plan` tool reaches the client only as `turn/plan/updated`;
/// each update becomes its own completed task-list tool part, ordinal-numbered
/// within its turn. Empty plans and turn-less notifications render nothing.
fn plan_update_part(
existing: &[WirePart],
thread: Option<&str>,
params: &Value,
) -> Option<WirePart> {
let plan = params
.get("plan")
.filter(|plan| plan.as_array().is_some_and(|steps| !steps.is_empty()))?;
let turn_id = params.get("turnId").and_then(Value::as_str)?;
let prefix = match thread {
Some(tid) => namespaced_part_id(tid, &format!("{turn_id}-plan-")),
None => format!("{turn_id}-plan-"),
};
let ordinal = existing
.iter()
.filter(|part| part.id.starts_with(&prefix))
.count();
let mut input = serde_json::Map::new();
input.insert("plan".into(), plan.clone());
if let Some(explanation) = params.get("explanation").filter(|v| v.is_string()) {
input.insert("explanation".into(), explanation.clone());
}
Some(tool_part(
format!("{prefix}{ordinal}"),
"update_plan",
"completed",
Some(Value::Object(input)),
None,
))
}

/// Whether the assistant message already carries a part with this id.
fn part_exists(ctx: &TurnCtx, id: &str) -> bool {
ctx.assistant.parts.iter().any(|p| p.id == id)
Expand Down Expand Up @@ -1769,6 +1808,11 @@ fn apply_sub_notification(
// add transcript parts here (`route_sub_event` handles the thread's
// terminal turn notifications and mirrors liveness onto the spawn
// part), and crucially never end the parent turn.
"turn/plan/updated" => {
if let Some(part) = plan_update_part(bucket, Some(tid), params) {
upsert_preserving_children(bucket, part);
}
}
_ => {}
}
discovered
Expand Down Expand Up @@ -3902,6 +3946,56 @@ requires_openai_auth = false
);
}

#[test]
fn plan_updates_become_task_list_tool_parts() {
let mut ctx = TurnCtx::test_stub();
let first = serde_json::json!({
"threadId": "t1", "turnId": "turn1", "explanation": "Starting",
"plan": [
{ "step": "Inspect the loader", "status": "inProgress" },
{ "step": "Patch it", "status": "pending" }
]
});
let second = serde_json::json!({
"threadId": "t1", "turnId": "turn1",
"plan": [
{ "step": "Inspect the loader", "status": "completed" },
{ "step": "Patch it", "status": "inProgress" }
]
});
let empty = serde_json::json!({ "threadId": "t1", "turnId": "turn1", "plan": [] });
let turnless =
serde_json::json!({ "threadId": "t1", "plan": [{ "step": "x", "status": "pending" }] });
assert!(apply_notification(&mut ctx, "turn/plan/updated", &first).is_none());
assert!(apply_notification(&mut ctx, "turn/plan/updated", &empty).is_none());
assert!(apply_notification(&mut ctx, "turn/plan/updated", &turnless).is_none());
assert!(apply_notification(&mut ctx, "turn/plan/updated", &second).is_none());

let parts = &ctx.assistant.parts;
assert_eq!(parts.len(), 2, "one part per non-empty update: {parts:?}");
assert_eq!(parts[0].id, "turn1-plan-0");
assert_eq!(parts[1].id, "turn1-plan-1");
for part in parts {
assert_eq!(part.kind, "tool");
assert_eq!(part.tool.as_deref(), Some("update_plan"));
assert_eq!(part.state.as_ref().unwrap().status, "completed");
}
let first_input = parts[0].state.as_ref().unwrap().input.as_ref().unwrap();
assert_eq!(first_input["explanation"], "Starting");
assert_eq!(first_input["plan"][0]["status"], "inProgress");
let second_input = parts[1].state.as_ref().unwrap().input.as_ref().unwrap();
assert!(second_input.get("explanation").is_none());
assert_eq!(second_input["plan"][1]["status"], "inProgress");

// A sub-agent's plan lands in its own bucket under a thread-scoped id.
let mut bucket = Vec::new();
let discovered = apply_sub_notification(&mut bucket, "sub1", "turn/plan/updated", &first);
assert!(discovered.is_empty());
assert_eq!(bucket.len(), 1);
assert_eq!(bucket[0].id, "sub1:turn1-plan-0");
assert_eq!(bucket[0].tool.as_deref(), Some("update_plan"));
}

#[test]
fn native_history_restores_a_missed_failed_command() {
let mut parts = vec![tool_part(
Expand Down
Loading
Loading