Merge develop into T-693 and reconcile Subject behavior with Memory information

Normal merge of develop@b0bce50b retains T-692 state hierarchy, diagnostics disclosures, ready-empty and candidate-only regressions alongside T-693 behavior/CAS/route fences. ResidentContext load and refresh retain the five-second timeout from 685b8aa6, with a seven-request regression.

Validation: root cargo check; full worker(serial), session-store, workspace-server and server-api(typescript) suites; frontend check with zero diagnostics; 464 Web tests and 171 component tests (19 Memory); production Memory browser-shell test/build; fmt/diff/OpenAPI checks.
This commit is contained in:
2026-10-05 17:31:17 +09:00
49 changed files with 2998 additions and 299 deletions
Generated
+7
View File
@@ -64,6 +64,7 @@ dependencies = [
"dotenv",
"eventsource-stream",
"futures",
"imagesize",
"reqwest",
"schemars",
"serde",
@@ -2188,6 +2189,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "include_dir"
version = "0.7.4"
+1
View File
@@ -32,6 +32,7 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
eventsource-stream = "0.2"
zstd = "0.13"
base64 = "0.22.1"
imagesize = "0.14"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
agen-macros = { workspace = true }
+45 -1
View File
@@ -626,6 +626,10 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// engine history. An error rejects the item and aborts the turn, allowing
/// upper layers to make durable storage the commit gate.
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
/// Durable host commit gate for image-result corrections. Runs before the
/// correction becomes live or a replacement request can be sent.
image_rejection_handler:
Option<Box<dyn Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync>>,
/// Request configuration (max_tokens, temperature, etc.)
request_config: RequestConfig,
/// Cancel notification channel (for interrupting execution)
@@ -971,6 +975,16 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
self.history_append_cbs.push(Box::new(callback));
}
/// Set the host's durable commit gate for correcting a rejected ViewImage
/// result. The original annotation/identity must be preserved. A failed
/// commit leaves both history and the outgoing request untouched.
pub fn set_image_rejection_handler(
&mut self,
handler: impl Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync + 'static,
) {
self.image_rejection_handler = Some(Box::new(handler));
}
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
for cb in &self.history_append_cbs {
cb(item).map_err(EngineError::HistoryAppend)?;
@@ -3205,7 +3219,34 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
"Sending request to LLM"
);
let stream = self.open_stream_with_retry(request, turn, llm_call).await?;
let mut request = request;
let stream = loop {
match self
.open_stream_with_retry(request.clone(), turn, llm_call)
.await
{
Ok(stream) => break stream,
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
let Some((history_index, request_index)) =
crate::image_recovery::largest_candidate(history, &request)
else {
return Err(EngineError::Client(error));
};
let entry = &history.entries()[history_index];
let replacement = crate::image_recovery::rejected_result(&entry.item);
if let Some(commit) = &self.image_rejection_handler {
commit(entry, &replacement).map_err(EngineError::HistoryAppend)?;
}
history.entries_mut()[history_index].item = replacement.clone();
// Rebuild the provider request from the corrected projection,
// retaining prior prune/interceptor decisions. No tool runs
// or assistant output have occurred for this failed request.
request.items[request_index] = replacement;
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
}
Err(error) => return Err(error),
}
};
self.tool_call_collector.begin_response();
let mut stream = ResponseStreamPump::start(stream);
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
@@ -3567,6 +3608,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
warning_cbs: Vec::new(),
tool_result_cbs: Vec::new(),
history_append_cbs: Vec::new(),
image_rejection_handler: None,
request_config: RequestConfig::default(),
cancel_tx,
cancel_rx,
@@ -3856,6 +3898,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
image_rejection_handler: self.image_rejection_handler,
request_config: self.request_config,
cancel_tx: self.cancel_tx,
@@ -4040,6 +4083,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
image_rejection_handler: self.image_rejection_handler,
request_config: self.request_config,
cancel_tx: self.cancel_tx,
+106
View File
@@ -0,0 +1,106 @@
//! Recovery is driven by provider rejection, never a locally chosen image limit.
use crate::{History, Item, Role, ToolResultDisposition, llm_client::Request, tool::Attachment};
pub(crate) const REJECTION_MESSAGE: &str = "ViewImage image omitted from model input after the provider rejected image size. This is a recovery candidate, not proof that this particular image caused the rejection. Resize or crop the image and retry ViewImage; do not read the unchanged image again.";
/// Keep the most recent response's tool results eligible, including early tool
/// results interleaved with that response's later text/calls. Empty boundaries
/// from older versions' failed requests are not evidence of accepted input.
pub(crate) fn accepted_input_end<'a>(items: impl Iterator<Item = &'a Item>) -> Option<usize> {
let mut response_start = None;
let mut accepted_prefix = None;
for (index, item) in items.enumerate() {
match item {
Item::AssistantResponseBoundary { .. } => response_start = Some(index),
Item::Message {
role: Role::Assistant,
..
}
| Item::ToolCall { .. }
| Item::Reasoning { .. } => {
accepted_prefix = Some(response_start.unwrap_or(index));
}
Item::Message {
role: Role::User, ..
} => response_start = None,
_ => {}
}
}
accepted_prefix
}
/// Select only a ViewImage result actually sent in the rejected request. The
/// tool contract returns exactly one image, so each correction removes one
/// image and preserves the tool call/result pair. Other tools and user uploads
/// are deliberately not rewritten by this recovery policy.
pub(crate) fn largest_candidate<A>(
history: &History<A>,
request: &Request,
) -> Option<(usize, usize)> {
let start = accepted_input_end(history.items()).unwrap_or(0);
let mut largest = None;
for (index, entry) in history.entries().iter().enumerate().skip(start) {
let Item::ToolResult {
call_id,
attachments,
..
} = &entry.item
else {
continue;
};
let [Attachment::Image(image)] = attachments.as_slice() else {
continue;
};
let from_view_image = history.items().any(|item| {
matches!(
item,
Item::ToolCall { call_id: id, name, .. } if id == call_id && name == "ViewImage"
)
});
if !from_view_image {
continue;
}
let sent = request.items.iter().position(|item| {
matches!(
item,
Item::ToolResult { call_id: id, attachments: sent, .. }
if id == call_id && sent == attachments
)
});
let Some(request_index) = sent else { continue };
// Header inspection only; no raster allocation or size threshold.
// Unknown dimensions sort by bytes as a deterministic fallback.
let pixels = imagesize::blob_size(image.data())
.ok()
.map(|size| (size.width as u64).saturating_mul(size.height as u64))
.unwrap_or(0);
let rank = (pixels, image.data().len(), index);
if largest
.as_ref()
.is_none_or(|(previous, _)| rank > *previous)
{
largest = Some((rank, (index, request_index)));
}
}
largest.map(|(_, candidate)| candidate)
}
pub(crate) fn rejected_result(item: &Item) -> Item {
let mut item = item.clone();
if let Item::ToolResult {
summary,
content,
attachments,
disposition,
is_error,
..
} = &mut item
{
*summary = REJECTION_MESSAGE.to_owned();
*content = None;
attachments.clear();
*disposition = ToolResultDisposition::Error;
*is_error = true;
}
item
}
+1
View File
@@ -3,6 +3,7 @@
mod engine;
mod handler;
mod history;
mod image_recovery;
mod message;
pub(crate) mod callback;
+29
View File
@@ -90,6 +90,35 @@ impl ClientError {
}
}
/// A provider-declared image size rejection, not a generic invalid input.
/// Some providers (including OpenAI) expose only `invalid_value` here, so
/// narrowly recognize their image-size diagnostic without hardcoded limits.
pub fn is_image_size_rejection(&self) -> bool {
let Self::Api {
status: Some(400 | 413 | 422),
code,
message,
..
} = self
else {
return false;
};
if matches!(
code.as_deref(),
Some("image_too_large" | "image_size_exceeded")
) {
return true;
}
let message = message.to_ascii_lowercase();
message.contains("image")
&& ((message.contains("patches")
&& message.contains("exceed")
&& message.contains("limit"))
|| message.contains("image is too large")
|| message.contains("image too large")
|| (message.contains("image dimensions") && message.contains("exceed")))
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
ClientError::Api { retry_after, .. } => *retry_after,
+6 -7
View File
@@ -136,14 +136,13 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
/// Remove binary attachments after the first model request that could consume them.
///
/// Attachments on tool results following the latest model output are fresh outputs for the
/// immediate follow-up request and remain visible. Once a later assistant/reasoning/tool-call
/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent
/// requests keep the durable tool summary and can call the originating tool again when the image
/// is still needed. Only the request-context clone is modified.
/// immediate follow-up request and remain visible. With early dispatch, later
/// text/calls in the same response have not consumed its tool results: use the
/// confirmed response boundary, not that response's last individual item.
/// Subsequent requests keep the durable tool summary and can call the originating
/// tool again when the image is still needed. Only the request-context clone is modified.
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
let latest_model_output = items.iter().rposition(|item| {
item.is_assistant_message() || item.is_reasoning() || item.is_tool_call()
});
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
let mut count = 0;
for (index, item) in items.iter_mut().enumerate() {
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
+350
View File
@@ -0,0 +1,350 @@
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
fn rejection() -> ClientError {
ClientError::Api { status: Some(400), code: Some("invalid_value".into()), message: "The image you provided requires 52150 patches after processing, exceeding the limit of 30000. Please resize the image and try again.".into(), retry_after: None }
}
fn image_result(id: &str, width: u32, height: u32, padding: usize) -> Item {
// PNG header: dimension inspection never decodes/allocates the pixel plane.
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
bytes.extend(width.to_be_bytes());
bytes.extend(height.to_be_bytes());
bytes.resize(40 + padding, 0);
Item::ToolResult {
id: None,
call_id: id.into(),
summary: "Attached image".into(),
content: None,
disposition: ToolResultDisposition::Success,
is_error: false,
attachments: vec![Attachment::Image(ImageAttachment::new("image/png", bytes))],
}
}
fn history() -> History {
History::from_items(vec![
Item::user_message("inspect"),
Item::tool_call("small", "ViewImage", "{}"),
Item::tool_call("large", "ViewImage", "{}"),
image_result("small", 10, 10, 2000),
image_result("large", 1600, 33349, 0),
])
}
fn images(request: &Request) -> Vec<String> {
request
.items
.iter()
.filter_map(|item| match item {
Item::ToolResult {
call_id,
attachments,
..
} if !attachments.is_empty() => Some(call_id.clone()),
_ => None,
})
.collect()
}
#[derive(Clone)]
struct Client {
requests: Arc<Mutex<Vec<Request>>>,
failures: usize,
other_error: bool,
}
impl Client {
fn new(failures: usize) -> Self {
Self {
requests: Arc::default(),
failures,
other_error: false,
}
}
}
#[async_trait]
impl LlmClient for Client {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let mut requests = self.requests.lock().unwrap();
requests.push(request);
if requests.len() <= self.failures {
return Err(rejection());
}
if self.other_error {
return Err(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "invalid tool arguments".into(),
retry_after: None,
});
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(Event::text_block_start(0)),
Ok(Event::text_delta(0, "resize and retry")),
Ok(Event::text_block_stop(0, None)),
Ok(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
#[tokio::test]
async fn removes_largest_pixels_not_largest_file_and_keeps_pairing() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
let mut engine = Engine::new(client);
let committed = Arc::new(Mutex::new(Vec::new()));
let commits = committed.clone();
engine.set_image_rejection_handler(move |original, replacement| {
commits
.lock()
.unwrap()
.push((original.item.clone(), replacement.clone()));
Ok(())
});
let output = engine.run(&mut history, "continue").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(images(&requests[0]), ["small", "large"]);
assert_eq!(images(&requests[1]), ["small"]);
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("retry ViewImage"))));
assert_eq!(committed.lock().unwrap().len(), 1);
assert_eq!(
history
.items()
.filter(|item| matches!(item, Item::ToolResult {call_id, ..} if call_id == "large"))
.count(),
1
);
}
#[tokio::test]
async fn multiple_rejections_remove_one_image_at_a_time_and_stop_at_exhaustion() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["small"]);
assert!(images(&requests[2]).is_empty());
}
#[tokio::test]
async fn unrelated_error_stops_recovery_without_removing_another_image() {
let mut client = Client::new(1);
client.other_error = true;
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(observer.requests.lock().unwrap().len(), 2);
assert!(history.items().any(|item| matches!(item, Item::ToolResult {call_id, attachments, ..} if call_id == "small" && !attachments.is_empty())));
}
#[tokio::test]
async fn persistence_failure_does_not_change_history_or_retry() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let original = history.items_cloned();
let mut engine = Engine::new(client);
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
let out = engine.run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
assert_eq!(observer.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn previously_accepted_images_are_not_recovery_candidates() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_message("I saw those images"));
history.push(Item::tool_call("new", "ViewImage", "{}"));
history.push(image_result("new", 1, 1, 0));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
// Already-consumed images are projected out, but their durable records
// must not be rewritten as failures.
assert!(images(&requests[1]).is_empty());
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult {call_id, is_error: false, attachments, ..} if (call_id == "small" || call_id == "large") && !attachments.is_empty())).count(), 2);
}
#[tokio::test]
async fn legacy_empty_failed_request_boundaries_do_not_hide_candidates() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_response_boundary("failed-1"));
history.push(Item::assistant_response_boundary("failed-2"));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(images(&observer.requests.lock().unwrap()[1]), ["small"]);
}
#[tokio::test]
async fn early_results_remain_candidates_despite_later_text_in_same_response() {
let client = Client::new(1);
let observer = client.clone();
let mut history = History::from_items(vec![
Item::user_message("inspect"),
Item::assistant_response_boundary("response-1"),
Item::tool_call("image", "ViewImage", "{}"),
image_result("image", 99, 99, 0),
Item::assistant_message("waiting for image"),
]);
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}; requests: {:?}",
out.result,
observer.requests.lock().unwrap()
);
assert!(images(&observer.requests.lock().unwrap()[1]).is_empty());
}
#[derive(Clone)]
struct CountingTool {
image: bool,
count: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl agen::tool::Tool for CountingTool {
async fn execute(
&self,
_: &str,
_: agen::tool::ToolExecutionContext,
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let attachments = if self.image {
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
unreachable!()
};
attachments
} else {
Vec::new()
};
Ok(agen::tool::ToolOutput {
summary: "executed once".into(),
content: None,
attachments,
})
}
}
#[derive(Clone)]
struct ToolClient(Client);
#[async_trait]
impl LlmClient for ToolClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
agen::llm_client::ToolCallCompletionSupport::PerBlock
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
if self.0.requests.lock().unwrap().is_empty() {
self.0.requests.lock().unwrap().push(request);
return Ok(Box::pin(futures::stream::iter(
vec![
Event::tool_use_start(0, "image", "ViewImage"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::tool_use_start(1, "side-effect", "Bash"),
Event::tool_input_delta(1, "{}"),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
.into_iter()
.map(Ok),
)));
}
self.0.stream(request).await
}
}
#[tokio::test]
async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
for mode in [
agen::ToolCallDispatchMode::AfterResponse,
agen::ToolCallDispatchMode::OnToolCallComplete,
] {
let client = Client::new(2);
let mut engine = Engine::new(ToolClient(client.clone()));
engine.set_tool_call_dispatch_mode(mode);
let mut counts = Vec::new();
for (name, image) in [("ViewImage", true), ("Bash", false)] {
let tool = CountingTool {
image,
count: Arc::default(),
};
counts.push(tool.count.clone());
engine.register_tool(Arc::new(move || {
(
agen::tool::ToolMeta::new(name)
.description("test")
.input_schema(serde_json::json!({"type":"object"})),
Arc::new(tool.clone()),
)
}));
}
let mut history = History::new();
let out = engine.run(&mut history, "inspect").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}",
out.result
);
assert!(
counts
.iter()
.all(|count| count.load(std::sync::atomic::Ordering::SeqCst) == 1)
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["image"]);
assert!(images(&requests[2]).is_empty());
}
}
#[test]
fn image_error_classifier_is_not_a_generic_400_retry() {
assert!(rejection().is_image_size_rejection());
for (status, message) in [
(400, "invalid image format"),
(400, "image URL not found"),
(400, "context window exceeds token limit"),
(401, "image is too large"),
(500, "image is too large"),
] {
assert!(
!ClientError::Api {
status: Some(status),
code: Some("invalid_value".into()),
message: message.into(),
retry_after: None
}
.is_image_size_rejection()
);
}
}
+1
View File
@@ -1071,6 +1071,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. } => 1,
_ => 0,
};
+297 -31
View File
@@ -7,7 +7,7 @@ use protocol::{
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use thiserror::Error;
@@ -139,6 +139,17 @@ struct RetainedHistoryCursor {
before_offset: u64,
record_end_offset: Option<u64>,
seed_entry_index: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
corrections: Vec<ToolCorrectionPosition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct ToolCorrectionPosition {
entry_id: String,
segment_id: SegmentId,
start: u64,
end: u64,
}
#[derive(Debug)]
@@ -445,6 +456,7 @@ pub fn read_retained_session_history_page(
limits,
&mut scanned_bytes,
&mut scanned_entries,
&mut Vec::new(),
)?;
Some(SessionHistoryLineageBoundary {
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
@@ -456,6 +468,10 @@ pub fn read_retained_session_history_page(
} else {
None
};
let mut correction_positions = cursor
.as_ref()
.map(|c| c.corrections.clone())
.unwrap_or_default();
let mut turns = read_history_turns_backward(
&store,
active.session_id,
@@ -465,11 +481,23 @@ pub fn read_retained_session_history_page(
limits,
&mut scanned_bytes,
&mut scanned_entries,
&mut correction_positions,
)?;
let has_more = turns.len() > page_limit;
if has_more {
turns.truncate(page_limit);
}
// Carry only corrections whose original results are still on older pages.
let returned_ids: HashSet<_> = turns
.iter()
.flat_map(|turn| {
turn.turn
.entries
.iter()
.map(|entry| entry.entry_id.as_str())
})
.collect();
correction_positions.retain(|position| !returned_ids.contains(position.entry_id.as_str()));
let next_cursor = if has_more {
let oldest = turns
.last()
@@ -485,6 +513,7 @@ pub fn read_retained_session_history_page(
before_offset: oldest.position.before_offset,
record_end_offset: oldest.position.record_end_offset,
seed_entry_index: oldest.position.seed_entry_index,
corrections: correction_positions,
})?)
} else {
None
@@ -702,6 +731,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. } => 1,
_ => 0,
};
@@ -814,10 +844,56 @@ fn read_history_turns_backward(
limits: RetainedHistoryReadLimits,
scanned_bytes: &mut u64,
scanned_entries: &mut usize,
correction_positions: &mut Vec<ToolCorrectionPosition>,
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
let mut turns = Vec::new();
let mut pending_entries = Vec::new();
let mut seen_entries = HashSet::new();
let mut corrections = HashMap::<String, SessionSnapshotEntryData>::new();
// Carry only bounded log positions, never correction text or image bodies,
// across pages. Re-read and validate the referenced records from adopted
// lineage rather than rescanning an arbitrarily large newer suffix.
for position in correction_positions.iter() {
let segment = lineage
.iter()
.find(|s| s.segment_id == position.segment_id)
.ok_or(RetainedHistoryReadError::InvalidCursor)?;
let adopted_end = locate_adopted_segment_end(
store,
session_id,
segment,
limits,
scanned_bytes,
scanned_entries,
)?;
if position.start >= position.end || position.end > adopted_end {
return Err(RetainedHistoryReadError::InvalidCursor);
}
let record = store
.read_log_record_range_read_only_bounded(
session_id,
position.segment_id,
position.start,
position.end,
limits.max_scan_bytes.saturating_sub(*scanned_bytes),
)
.map_err(map_history_cursor_store_error)?;
*scanned_bytes = scanned_bytes
.checked_add(record.end_offset - record.start_offset)
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
*scanned_entries = scanned_entries
.checked_add(persisted_entry_units(std::slice::from_ref(&record.entry)))
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
if *scanned_entries > limits.max_entries {
return Err(RetainedHistoryReadError::ResourceLimit);
}
if !matches!(&record.entry, LogEntry::ToolResultCorrected { entry, .. } if entry.metadata.entry_id.0 == position.entry_id)
|| !capture_tool_correction(&record.entry, &mut corrections)
{
return Err(RetainedHistoryReadError::InvalidCursor);
}
}
let (start_segment_index, start_position, cursor_turn_id) = match start {
Some((position, turn_id)) => {
@@ -878,32 +954,6 @@ fn read_history_turns_backward(
for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) {
let starting_here = segment_index == start_segment_index;
if starting_here
&& start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.is_some()
{
let seed_index = start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.expect("checked seed cursor");
if collect_history_entries_backward(
&segment.seed_entries[..seed_index],
segment.segment_id,
0,
None,
true,
turn_limit,
&mut seen_entries,
&mut pending_entries,
&mut turns,
) {
return Ok(turns);
}
continue;
}
let adopted_end = locate_adopted_segment_end(
store,
session_id,
@@ -923,6 +973,32 @@ fn read_history_turns_backward(
if before_offset > adopted_end {
return Err(RetainedHistoryReadError::InvalidCursor);
}
if starting_here
&& start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.is_some()
{
let seed_index = start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.expect("checked seed cursor");
if collect_history_entries_backward(
&corrected_history_entries(&segment.seed_entries[..seed_index], &corrections),
segment.segment_id,
0,
None,
true,
turn_limit,
&mut seen_entries,
&mut pending_entries,
&mut turns,
) {
return Ok(turns);
}
continue;
}
let mut reader = store
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
.map_err(map_history_store_error)?;
@@ -949,7 +1025,25 @@ fn read_history_turns_backward(
}
continue;
}
let entries = project_history_record(session_id, segment.segment_id, &record.entry);
if capture_tool_correction(&record.entry, &mut corrections) {
let LogEntry::ToolResultCorrected { entry, .. } = &record.entry else {
unreachable!()
};
let position = ToolCorrectionPosition {
entry_id: entry.metadata.entry_id.0.clone(),
segment_id: segment.segment_id,
start: record.start_offset,
end: record.end_offset,
};
if !correction_positions.contains(&position) {
correction_positions.push(position);
}
continue;
}
let entries = corrected_history_entries(
&project_history_record(session_id, segment.segment_id, &record.entry),
&corrections,
);
if collect_history_entries_backward(
&entries,
segment.segment_id,
@@ -967,7 +1061,7 @@ fn read_history_turns_backward(
if segment_index + 1 == lineage.len()
&& collect_history_entries_backward(
&segment.seed_entries,
&corrected_history_entries(&segment.seed_entries, &corrections),
segment.segment_id,
0,
None,
@@ -984,6 +1078,38 @@ fn read_history_turns_backward(
Ok(turns)
}
fn capture_tool_correction(
record: &LogEntry,
corrections: &mut HashMap<String, SessionSnapshotEntryData>,
) -> bool {
let LogEntry::ToolResultCorrected { entry, .. } = record else {
return false;
};
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
// We read backwards: the first correction seen is the latest one.
corrections
.entry(entry.metadata.entry_id.0.clone())
.or_insert(data);
}
true
}
fn corrected_history_entries(
entries: &[SessionSnapshotEntry],
corrections: &HashMap<String, SessionSnapshotEntryData>,
) -> Vec<SessionSnapshotEntry> {
entries
.iter()
.cloned()
.map(|mut entry| {
if let Some(data) = corrections.get(&entry.entry_id) {
entry.data = data.clone();
}
entry
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn collect_history_entries_backward(
entries: &[SessionSnapshotEntry],
@@ -1052,9 +1178,13 @@ fn is_user_entry(entry: &SessionSnapshotEntry) -> bool {
fn encode_history_cursor(
cursor: &RetainedHistoryCursor,
) -> Result<String, RetainedHistoryReadError> {
serde_json::to_vec(cursor)
let encoded = serde_json::to_vec(cursor)
.map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
.map_err(|_| RetainedHistoryReadError::CorruptLog)
.map_err(|_| RetainedHistoryReadError::CorruptLog)?;
if encoded.len() > 4096 {
return Err(RetainedHistoryReadError::ResourceLimit);
}
Ok(encoded)
}
fn decode_history_cursor(cursor: &str) -> Result<RetainedHistoryCursor, RetainedHistoryReadError> {
@@ -1252,6 +1382,19 @@ pub(crate) fn project_session_snapshot_for_segment(
entries.push(history_entry(entry, *ts, data));
}
}
LogEntry::ToolResultCorrected { ts, entry } => {
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
if let Some(existing) = entries
.iter_mut()
.find(|existing| existing.entry_id == entry.metadata.entry_id.0)
{
// The original result keeps its place/time in the conversation.
existing.data = data;
} else {
entries.push(history_entry(entry, *ts, data));
}
}
}
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
@@ -1782,6 +1925,129 @@ mod tests {
.collect()
}
#[test]
fn image_correction_keeps_original_position_across_history_pages() {
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
let mut image = assistant_message(1, "image");
image.item = agen::Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"original image",
None,
agen::ToolResultDisposition::Success,
vec![agen::tool::Attachment::Image(
agen::tool::ImageAttachment::new("image/png", vec![1_u8, 2, 3]),
)],
)
.into();
let mut corrected = image.clone();
corrected.item = agen::Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"Resize or crop and retry ViewImage",
None,
agen::ToolResultDisposition::Error,
Vec::new(),
)
.into();
let mut log = vec![segment_start(session_id, Vec::new(), None, None)];
append_turn(&mut log, 1);
log.push(LogEntry::AnnotatedToolResult {
ts: 15,
entry: image.clone(),
});
append_turn(&mut log, 2);
append_turn(&mut log, 3);
log.push(LogEntry::ToolResultCorrected {
ts: 35,
entry: corrected,
});
let (_root, aggregate_root) = persist_history_fixture(
"corrected-image",
session_id,
segment_id,
vec![(segment_id, log.clone())],
);
let first = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
None,
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&first), ["user-3"]);
assert!(
!first.turns[0]
.entries
.iter()
.any(|entry| entry.entry_id == image.metadata.entry_id.0)
);
let second = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
first.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&second), ["user-2"]);
let third = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
second.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&third), ["user-1"]);
let entry = third.turns[0]
.entries
.iter()
.find(|entry| entry.entry_id == image.metadata.entry_id.0)
.unwrap();
assert_eq!(entry.timestamp, 15);
assert!(
matches!(&entry.data, SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
assert_eq!(
*entry,
*project_session_snapshot(session_id, &log)
.entries
.iter()
.find(|entry| entry.entry_id == image.metadata.entry_id.0)
.unwrap()
);
let restored = crate::collect_state(&log);
assert_eq!(
restored
.annotated_history
.iter()
.filter(|entry| entry.metadata.entry_id == image.metadata.entry_id)
.count(),
1
);
assert!(
matches!(&restored.annotated_history.iter().find(|entry| entry.metadata.entry_id == image.metadata.entry_id).unwrap().item, LoggedItem::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
// Cursor pointers are only hints: the referenced record is read under
// current lineage authority and must actually be a correction record.
let mut cursor = decode_history_cursor(first.next_cursor.as_deref().unwrap()).unwrap();
cursor.corrections[0].segment_id = crate::new_segment_id();
let forged = encode_history_cursor(&cursor).unwrap();
assert_eq!(
read_retained_session_history_page(
&aggregate_root,
"corrected-image",
Some(&forged),
Some(1),
RetainedHistoryReadLimits::default()
)
.unwrap_err(),
RetainedHistoryReadError::InvalidCursor
);
}
#[test]
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
let worker_name = "worker-history-12";
+13
View File
@@ -107,6 +107,9 @@ pub enum LogEntry {
/// Canonical tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// Append-only correction of a provider-rejected image result. Retains the
/// original stable identity; replay replaces rather than appends the item.
ToolResultCorrected { ts: u64, entry: LoggedHistoryEntry },
/// Canonical typed system event and model-visible metadata committed
/// together.
@@ -399,6 +402,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::ToolResultCorrected { entry, .. } => {
if let Some(index) = state
.annotated_history
.iter()
.position(|existing| existing.metadata.entry_id == entry.metadata.entry_id)
{
state.annotated_history[index] = entry.clone();
state.history[index] = Item::from(entry.item.clone());
}
}
LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
+32 -5
View File
@@ -381,10 +381,10 @@ impl RuntimeOwnedWorkspaceClient {
self
}
fn execute_with_permission(
fn execute_with_optional_timeout(
&self,
request: WorkspaceRequest,
permission: &'static str,
timeout: Option<Duration>,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
@@ -409,9 +409,9 @@ impl RuntimeOwnedWorkspaceClient {
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
@@ -478,7 +478,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION)
self.execute_with_optional_timeout(request, self.request_timeout)
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute_with_optional_timeout(request, Some(timeout))
}
fn execute_server_operation(
@@ -1341,6 +1349,25 @@ mod tests {
server.join().unwrap();
}
#[test]
fn runtime_owned_workspace_client_honors_per_request_timeouts() {
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
let client =
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
let error = client
.execute_with_timeout(
WorkspaceRequest::json(WorkspaceRequestMethod::Post, "/api/test", "{}"),
Duration::from_millis(20),
)
.unwrap_err();
assert_eq!(client.request_timeout, None);
let message = error.to_string();
assert!(message.contains("POST /api/test"), "{message}");
assert!(message.contains("timed out"), "{message}");
server.join().unwrap();
}
#[test]
fn runtime_owned_workspace_client_reports_request_timeouts() {
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
+51 -9
View File
@@ -6,6 +6,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use arc_swap::ArcSwap;
@@ -157,9 +158,24 @@ impl dyn WorkspaceClient + '_ {
}
}
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn execute_subjektiv_memory_operation(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
execute_subjektiv_memory_operation_with(client, operation, |client, request| {
client.execute(request)
})
}
fn execute_subjektiv_memory_operation_with(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
execute: impl FnOnce(
&dyn WorkspaceClient,
WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, WorkspaceClientError>,
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
let workspace_id =
client
@@ -170,11 +186,14 @@ async fn execute_subjektiv_memory_operation(
client.kind()
),
})?;
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
))?;
let response = execute(
client,
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
),
)?;
let status = reqwest::StatusCode::from_u16(response.status)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
if !response.is_success() {
@@ -712,12 +731,13 @@ impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation(
match execute_subjektiv_memory_operation_with(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
)
.await?
{
|client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
},
)? {
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
"unexpected subject resident context response: {other:?}"
@@ -1291,6 +1311,7 @@ mod tests {
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
}
impl SubjectResidentClient {
@@ -1304,6 +1325,7 @@ mod tests {
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
}
@@ -1314,6 +1336,7 @@ mod tests {
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
}
}
@@ -1367,6 +1390,15 @@ mod tests {
.unwrap(),
})
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.timeouts.lock().unwrap().push(timeout);
self.execute(request)
}
}
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
@@ -1681,6 +1713,10 @@ permission = "write"
ready_client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
);
assert_eq!(
ready_client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT]
);
let ready_empty_client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ready,
@@ -1790,6 +1826,12 @@ permission = "write"
.body
.contains("No user-managed behavior is set")
);
// Initial load and every live update/clear/replay probe retain the
// target-side bounded resident request contract.
assert_eq!(
client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
);
}
#[tokio::test]
+221
View File
@@ -0,0 +1,221 @@
//! Recovery tests use the normal Worker execution and append-log commit gate.
use super::*;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use async_trait::async_trait;
#[derive(Clone, Default)]
struct RejectImages {
requests: Arc<Mutex<Vec<Request>>>,
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
}
#[async_trait]
impl LlmClient for RejectImages {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let has_image = request.items.iter().any(
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty()),
);
self.requests.lock().unwrap().push(request);
if has_image {
return Err(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "The image requires 52150 patches, exceeding the limit of 30000.".into(),
retry_after: None,
});
}
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
cancel.try_send(()).unwrap();
return Ok(Box::pin(futures::stream::pending()));
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "Resize the source image")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
async fn image_worker() -> (
tempfile::TempDir,
Worker<RejectImages, session_store::FsStore>,
RejectImages,
) {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("session")).unwrap();
let manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "image-recovery-test"
[model]
scheme = "anthropic"
model_id = "test"
[engine]
[scope]
allow = []
"#,
)
.unwrap();
let client = RejectImages::default();
let mut worker = Worker::new(
manifest,
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
store,
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
Scope::writable(dir.path()).unwrap(),
)
.await
.unwrap();
worker.disable_manifest_lifecycle_features();
worker.set_history_for_test(vec![
Item::user_message("inspect this image"),
Item::tool_call("image-call", "ViewImage", "{}"),
Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"Attached image",
None,
agen::ToolResultDisposition::Success,
vec![Attachment::Image(ImageAttachment::new(
"image/png",
vec![1_u8, 2, 3],
))],
),
]);
worker.wire_history_persistence();
(dir, worker, client)
}
fn rejected(item: &Item) -> bool {
matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty())
}
#[tokio::test]
async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() {
let (_dir, mut worker, client) = image_worker().await;
assert_eq!(
worker.run_text("continue").await.unwrap(),
WorkerRunResult::Finished
);
assert_eq!(client.requests.lock().unwrap().len(), 2);
let raw = worker
.store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
let correction = raw
.iter()
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
.unwrap()
.clone();
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
assert_eq!(
restored
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
assert_eq!(
session_store::collect_state(&raw)
.history
.iter()
.filter(|item| rejected(item))
.count(),
1
);
let LogEntry::ToolResultCorrected {
entry: corrected, ..
} = &correction
else {
unreachable!()
};
let original = match &raw[0] {
LogEntry::AnnotatedSegmentStart { history, .. } => history
.iter()
.find(|entry| entry.metadata.entry_id == corrected.metadata.entry_id)
.unwrap(),
_ => panic!("missing seed"),
};
assert!(
matches!(&original.item, session_store::LoggedItem::ToolResult { attachments, is_error: false, .. } if attachments.len() == 1)
);
assert_eq!(
restored
.iter()
.find(|entry| rejected(&entry.item))
.unwrap()
.annotation,
original.metadata
);
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&raw);
let projected = snapshot
.entries
.iter()
.find(|entry| entry.entry_id == corrected.metadata.entry_id.0)
.unwrap();
assert!(
matches!(&projected.data, protocol::SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
assert!(matches!(
crate::ipc::protocol_session::live_log_entry_event(correction),
Some(Event::SessionEntryCommitted { .. })
));
// Fork/compact seed writers serialize the effective annotated history.
let seed = LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: worker.session_id(),
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: restored.iter().map(to_logged_history_entry).collect(),
forked_from: None,
compacted_from: None,
};
let restored_again =
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
assert_eq!(
restored_again
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
let mut history = History::from_entries(restored_again);
let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone())
.run_with_annotation(&mut history, "continue after restart", &mut |_| {
Ok(SessionHistoryMetadata::legacy_unknown())
})
.await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(client.requests.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
let (_dir, mut worker, client) = image_worker().await;
*client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender());
let result = worker.run_text("continue").await.unwrap();
assert_eq!(result, WorkerRunResult::Cancelled);
let raw = worker
.store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
assert!(
raw.iter()
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
);
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
assert_eq!(
restored
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
}
@@ -76,6 +76,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
}
entry @ (LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunResumed { .. }
| LogEntry::RunCancelled { .. }
+1
View File
@@ -121,6 +121,7 @@ impl SegmentLogSink {
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
| LogEntry::RunYielded { .. }
+11
View File
@@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries(
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::ToolResultCorrected { entry, .. } => {
let target = history
.iter_mut()
.find(|existing| existing.annotation.entry_id == entry.metadata.entry_id)
.ok_or_else(|| "tool result correction target is missing".to_string())?;
if !matches!((&target.item, &entry.item), (Item::ToolResult { call_id, .. }, session_store::LoggedItem::ToolResult { call_id: replacement, .. }) if call_id == replacement)
{
return Err("tool result correction changed call identity".to_string());
}
*target = from_logged(entry);
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
+33
View File
@@ -1,3 +1,7 @@
#[cfg(test)]
#[path = "image_recovery_tests.rs"]
mod image_recovery_tests;
use std::collections::{HashMap, VecDeque};
#[cfg(test)]
use std::path::Path;
@@ -734,6 +738,17 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
fn execute(&self, request: WorkspaceRequest)
-> Result<WorkspaceResponse, WorkspaceClientError>;
/// Executes one bounded Workspace request when the caller is on a
/// latency-sensitive lifecycle boundary. Implementations without transport
/// timeout support retain their existing execution semantics.
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
_timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute(request)
}
fn execute_server_operation(
&self,
operation: WorkspaceServerOperation,
@@ -4061,6 +4076,24 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
St: Clone + 'static,
{
self.materialize_durable_session_head().await?;
let writer = self.log_writer_handle();
let activity = self.ai_activity_counter.clone();
self.engine_mut()
.set_image_rejection_handler(move |original, replacement| {
writer
.append_entry(LogEntry::ToolResultCorrected {
ts: segment_log::now_millis(),
entry: session_store::LoggedHistoryEntry {
item: replacement.clone().into(),
metadata: original.annotation.clone(),
},
})
.map_err(|error| error.to_string())?;
// A later Cancel must not roll back this committed correction while
// leaving the in-memory result changed (the original may predate Run).
activity.fetch_add(1, Ordering::SeqCst);
Ok(())
});
self.ensure_interceptor_installed();
if self.should_pre_run_compact() {
self.try_pre_run_compact().await?;
+82 -6
View File
@@ -162,11 +162,11 @@ use crate::hosts::{
RuntimeRegistryError, RuntimeRegistryUnregisterResult, TicketWorkerRole,
WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
WorkerInputDisposition, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceRequirement,
WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest,
WorkerTicketAssignmentRequest, WorkspaceRuntimeAuthorization,
is_disallowed_remote_runtime_address, is_loopback_runtime_origin,
worker_spawn_create_fingerprint, workspace_worker_summary,
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceEvidence,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerSpawnWorkingDirectoryRequest, WorkerTicketAssignmentRequest,
WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address,
is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary,
};
use crate::memory_backend::execute_memory_backend_operation_with_authority;
use crate::memory_staging::{
@@ -27712,6 +27712,11 @@ async fn create_workspace_worker_inner(
Ok(result) => result,
Err(error) => return Err(error.into()),
};
let result = if resolved_subjektiv_attached {
restore_reused_subject_worker(&api, result)?
} else {
result
};
Ok(Json(record_browser_worker_spawn(
&api,
runtime_id,
@@ -27721,6 +27726,52 @@ async fn create_workspace_worker_inner(
)?))
}
fn restore_reused_subject_worker(
api: &WorkspaceApi,
mut result: WorkerSpawnResult,
) -> ApiResult<WorkerSpawnResult> {
if result.state != InternalWorkerOperationState::Accepted {
return Ok(result);
}
let Some(worker) = result.worker.as_ref() else {
return Ok(result);
};
if worker.state != "stopped" {
return Ok(result);
}
let worker_ref = worker.worker.clone();
let restored = api.restore_workspace_worker(&worker_ref)?;
if restored.state != server_api::WorkerRestoreState::Accepted {
let message = runtime_diagnostics_message(&restored.diagnostics);
return Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id: worker_ref.runtime_id,
code: "subject_worker_restore_rejected".to_string(),
message,
},
restored.diagnostics,
));
}
let worker = restored
.worker
.ok_or_else(|| Error::RuntimeOperationFailed {
runtime_id: worker_ref.runtime_id,
code: "subject_worker_restore_missing_summary".to_string(),
message: "Runtime restored the existing subject Worker without returning its summary"
.to_string(),
})?;
result.worker = Some(worker);
result.diagnostics.extend(restored.diagnostics);
result
.acceptance_evidence
.push(WorkerSpawnAcceptanceEvidence {
kind: "subject_worker_restored".to_string(),
detail: "the existing stopped subject Worker was restored".to_string(),
});
Ok(result)
}
fn record_browser_worker_spawn(
api: &WorkspaceApi,
requested_runtime_id: String,
@@ -39609,7 +39660,7 @@ mod tests {
async fn ordinary_worker_launch_validates_optional_subjektiv_connection() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
let store = open_subjektiv_store(&api).unwrap();
let active = store
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
@@ -39704,6 +39755,31 @@ mod tests {
first.worker.singleton_key.as_deref(),
Some(format!("subjektiv:{}", active.id).as_str())
);
let worker = RuntimeWorkerRef::new(&first.runtime_id, &first.worker_id);
let stopped = api
.runtime
.stop_worker(
&worker,
WorkerLifecycleRequest {
reason: Some("test subject Worker restart".to_string()),
ticket_assignment: None,
},
)
.unwrap();
assert_eq!(stopped.state, InternalWorkerOperationState::Accepted);
assert_eq!(api.runtime.worker(&worker).unwrap().state, "stopped");
execution.accept_restores();
let Json(restored) = create_workspace_worker(
State(api.clone()),
HeaderMap::new(),
Json(request(Some(&active.id), "builtin:companion", None)),
)
.await
.unwrap();
assert_eq!(restored.worker_id, first.worker_id);
assert_eq!(restored.worker.state, "idle");
}
#[tokio::test]
@@ -0,0 +1,133 @@
# Subject / Memory information audit (T-692)
## Decision and scope
The previous Subjects index exposed `Store revision` alongside identity, state and
update time; Subject detail led with technical metadata and raw surface states.
These numbers require implementation knowledge before they help a user choose a
Subject or understand what is available. The revised hierarchy starts with
identity, connection, committed records and generated-context availability.
This is a presentation change, not a change to storage, freshness comparisons,
permissions, generation, or Worker history. T-688's ordinary Worker launch and
T-689's independent Subject creation remain the actual operation paths. T-693
owns editable user-managed behavior: that content must remain distinct from this
page's context generated from committed Memories. A successful read or a current
surface does not attest what is already in any running Worker's history.
## Field classification and before/after priorities
| Information | Classification and reason | Presentation decision |
| --- | --- | --- |
| Subject role and ID | Normal identification. Similar or identical roles need a stable identifier for selection and comparison. | Keep role as heading and the complete wrapping ID visible in index and detail. |
| Subject active/retired state | Normal operation decision: whether a new Worker connection can use the Subject. | Replace raw `active` with `Available`; keep `Retired`. Detail explains connection eligibility, not an assertion that a Worker is running. |
| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from store revision or claim all Workers received current Memory. Worker start remains the ordinary launch form. |
| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from revision or scan all pages. |
| List counts | Normal navigation context, not a store-wide statistic. | Label `N shown` and `more available`, preserving existing bounded cursor pagination. |
| Resident availability | Normal context decision. A missing surface is not missing committed Memory. | `Ready to use`, `Ready, no context`, `Not generated`, `Needs refresh`, `Generation failed`, and request `Unavailable` are distinct. These describe the current generated store artifact, not content truth or Worker-history propagation. |
| Resident body and generated time | Normal content/read freshness context. | Keep the shared Markdown renderer, generated date and exact snapshot reference count. Ready-empty explains that a valid snapshot has no body; stale/failed do not display a partial or old snapshot. |
| Subject store revision | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. |
| Subject update time | Secondary metadata; not a context-injection or generation completion timestamp. | Label `Last changed`; on detail put it with Subject diagnostics rather than competing with the availability summary. |
| Snapshot ID and built-from revision | Diagnostic correlation, distinct from per-Memory revisions. | Retain under `Surface sources and diagnostics`, labelled `Generated from Subject revision` and explained. |
| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable revision values and Memory links in the disclosure. |
| Memory claim, kind, lifecycle state, excerpt and body | Normal content decision. Active, resolved and retracted are not interchangeable. | Retain list scanning and body reading. Keep the Markdown renderer and current/historical distinction. |
| Individual Memory revision and history | Detailed content comparison, also useful for identifying a selected version. | Explicit `Memory revision` labels, separate from Subject counters. Preserve immutable history, selected revision links and revision pagination. |
| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-revision links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. |
| Raw field names / duplicated general revision terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare revision labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. |
No diagnostic datum was removed from Subject detail, surface detail or Memory
provenance/history. The index no longer shows a store revision, but the exact
value remains one click away in Subject detail. No invented regeneration,
editing or recovery action was added; `Needs refresh` does not mean an update job
is running. Creation still creates only a Subject, and selection still uses the
existing ordinary launch contract.
## Candidate-only verification
Candidates are not committed records and are not an input to this page. The
Subject loader performs just three bounded reads: Subject, resident surface and
`/memories?limit=100[&cursor=...]`. It does not read or count staging records.
The existing real-store regression
`subjektiv::tests::candidate_decisions_reject_conflicts_and_roll_back_partial_writes`
stages a candidate and attempts an invalid apply. It asserts the candidate has no
resolution and the committed Memory list remains empty. The component regression
`candidate-only Subjects show no committed Memories, not an inferred candidate count`
uses that read projection (store revision 0, ungenerated surface, empty successful
committed list) and checks `None`, `No committed Memories yet.`, `Not connected`
and `Not generated`, with no resident body. Thus a staged candidate does not turn
into a committed-Memory count or a claim that context has been generated.
The store test proves the persistence boundary; the component test proves the
rendering of its public projection. These are separate boundary tests, **not** a
live browser-to-Backend candidate-creation E2E. The existing `ungenerated` capture
represents the same visible projection, but does not itself stage a candidate.
## Visual review and validation evidence
The restored implementation is in `fbb09e65` and `4cf6c130`; this report/test
supplement does not change rendered markup, styling, copy or data contracts.
- Scenarios: `tools/web-ux/scenarios/memory-subjects-{light,dark}.json`.
- Before: `target/web-ux/t-692-before-{light,dark}` at `cc979874`.
- After: `target/web-ux/t-692-final2-{light,dark}` at clean `4cf6c130`.
- Comparisons: `target/web-ux/t-692-final2-{light,dark}-comparison`.
- Browser interactions: `target/web-ux/t-692-browser-interactions`.
- Persona: synthetic Workspace fixture; permitted creation and intentional
permission-rejection responses. No real account or private store data used.
- Viewports: 1440, 768, 390 and 320px; light and dark themes.
- States: connected/unconnected, active/retired, successful empty lists, ready
long Markdown, ready-empty, ungenerated, stale, failed, unavailable HTTP503,
create form and create permission error; expanded technical disclosures.
- The takeover implementer personally inspected individual before/after images
for index at 768px, representative detail at desktop/mobile and Memory detail,
then ready-empty mobile, ungenerated narrow, stale desktop, failed mobile,
endpoint error at 320px, mobile surface sources, compact Subject diagnostics
and mobile permission error. Oversized contact sheets were rejected by the
image provider; individual viewport PNGs were inspected instead.
- Findings/result: primary state groups replace counters without losing IDs or
document access; long IDs wrap, status is textual rather than color-only,
mobile groups stack, diagnostics remain readable, and error state regions stay
separate. No new visual defect identified. **Pass for the audited scope.**
- Final bundles contain 64 captures each. `completed-with-errors` is expected:
intentional creation HTTP403, endpoint HTTP503/associated aborted requests,
and the deliberately failed surface's alert are retained as evidence. Healthy
capture states have empty error arrays; this is not a blanket claim that all
captures were error-free. Review-context diagnostics are empty.
- The focused production-shell browser test proves main-owned vertical scrolling
with wheel/keyboard/touch, 320px/mobile shell behavior, no page-wide overflow,
local table/code scroll, Markdown safety, creation/launch separation, bounded
pagination and Memory/source/revision reachability.
Independent review at source `4cf6c130` ran root `cargo check`,
`cargo fmt --all -- --check`, diff hygiene, Web check (0 errors/warnings), 12
Memory API tests, 452 full Web unit tests, 134 component tests (8 Memory tests),
production build, the focused production-shell browser test (1), and Web-UX unit
tests (16), all passing.
The takeover implementer then validated this report/test supplement with:
- `cargo test -p yoi-workspace-server --lib candidate_decisions_reject_conflicts_and_roll_back_partial_writes`: 1 passed.
- Root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`: passed.
- Targeted Memory component tests: 9 passed, including the added candidate-only projection test.
- Web `deno task check`: 0 errors/warnings; `deno task test`: 452 passed;
`deno task test:component`: 135 passed.
- Scoped `deno fmt --check` on the changed TypeScript test: passed.
- JSON audit of both final2 bundles: 64 captures each, exact clean source
`4cf6c130`, and no errors outside the deliberate permission/error/failed routes.
The supplement changes only a document and a test, so it does not require new
rendered-UI captures. Rereview records and the final approved Ticket handoff link
this report to the new exact source.
Known non-blocking validation diagnostics: component runs emitted incidental
localhost:3000 connection-refused messages while passing; the production build
warned about a large chunk. A broader Web-UX typecheck fails in unchanged
`tools/web-ux/src/processes.ts:44` on a `TextDecoderStream` generic incompatibility;
this report does not claim that broad check passed.
No live-Backend browser interactions, real Worker model-request propagation,
regeneration, or server/runtime process E2E were performed. No Rust production
crate changed in T-692; the supplemental store test exercises an existing
contract without changing that contract. Backend counters, comparison logic,
storage schema and feature behavior remain intact.
@@ -596,6 +596,16 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
await touchPage.reload();
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
// Different-route and same-page links both dismiss the mobile overlay.
for (let visit = 0; visit < 2; visit++) {
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
await touchPage.getByRole("link", { name: "Workspace home", exact: true }).tap();
await touchPage.waitForURL(`**/w/${workspaceId}`);
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await touchPage.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
}
await touchPage.goto(page.url());
// A wide touchscreen still has a temporary-open control in hover mode.
await touchPage.setViewportSize({ width: 1024, height: 600 });
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
@@ -64,6 +64,104 @@ Deno.test("Console pending scopes are independent and match Tasks row typography
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
);
await page.locator(".task-mini-row").waitFor();
const metadata = page.getByLabel("Worker model and context", { exact: true });
const statusGeometry = await metadata.evaluate((status) => {
const task = document.querySelector(".task-mini-row")!;
const composer = document.querySelector(".console-composer")!;
const metrics = (node: Element) => {
const style = getComputedStyle(node);
return [style.fontFamily, style.fontSize, style.lineHeight];
};
const rect = status.getBoundingClientRect();
return {
typography: metrics(status),
taskTypography: metrics(task),
height: rect.height,
belowComposer: rect.top >= composer.getBoundingClientRect().bottom,
alignedWithTasks: rect.left + parseFloat(getComputedStyle(status).paddingLeft) === task.getBoundingClientRect().left,
visible: rect.bottom <= innerHeight,
wrap: getComputedStyle(status).flexWrap,
pageOverflow: document.documentElement.scrollWidth > innerWidth,
};
});
assertEquals(statusGeometry.typography, statusGeometry.taskTypography);
assertEquals(statusGeometry.height, 24);
assertEquals(statusGeometry.belowComposer, true);
assertEquals(statusGeometry.alignedWithTasks, true);
assertEquals(statusGeometry.visible, true);
assertEquals(statusGeometry.wrap, "nowrap");
assertEquals(statusGeometry.pageOverflow, false);
const details = metadata.getByRole("button", { name: "Details", exact: true });
assertEquals(await page.locator(".console-header").count(), 0);
const detailsGeometry = await details.evaluate((button) => {
const row = button.closest(".worker-context-status")!;
const rect = button.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
return {
rightAligned: Math.abs(rect.right - (rowRect.right - parseFloat(getComputedStyle(row).paddingRight))) < 1,
centered: Math.abs(rect.top + rect.height / 2 - (rowRect.top + rowRect.height / 2)) < 1,
width: rect.width,
};
});
assertEquals(detailsGeometry.rightAligned, true);
assertEquals(detailsGeometry.centered, true);
assertEquals(detailsGeometry.width, 24);
const overview = metadata.getByRole("switch", { name: "Overview", exact: true });
const controls = await overview.evaluate((group) => {
const row = group.closest(".worker-context-status")!;
const rowRect = row.getBoundingClientRect();
const buttons = [...row.querySelectorAll("button")];
return {
buttonsInsideRow: buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= rowRect.left && rect.right <= rowRect.right &&
rect.top >= rowRect.top && rect.bottom <= rowRect.bottom;
}),
heights: buttons.map((button) => button.getBoundingClientRect().height),
modeFont: getComputedStyle(buttons[0]).fontFamily,
divider: getComputedStyle(buttons[1]).borderLeftWidth,
beforeDetails: group.nextElementSibling?.getAttribute("aria-label") === "Details",
};
});
assertEquals(controls.buttonsInsideRow, true);
assertEquals(controls.heights, [24, 24]);
assertEquals(controls.modeFont.includes("mono"), false);
assertEquals(controls.divider, "0px");
assertEquals(controls.beforeDetails, true);
const onSize = (await overview.boundingBox())!;
assert(onSize.width < 90, "Switch and fixed label should use less than 90px");
assertEquals(await overview.getAttribute("aria-checked"), "true");
await overview.focus();
assertEquals(await overview.evaluate((button) => getComputedStyle(button).outlineStyle), "solid");
await page.keyboard.press("Enter");
assertEquals(await overview.getAttribute("aria-checked"), "false");
assertEquals((await overview.textContent())!.trim(), "Overview");
assertEquals((await overview.boundingBox())!.width, onSize.width);
if (mode === "both") {
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}-off.png`) });
}
await page.keyboard.press("Space");
assertEquals(await overview.getAttribute("aria-checked"), "true");
assertEquals((await overview.boundingBox())!.width, onSize.width);
await overview.locator("span").last().click();
assertEquals(await overview.getAttribute("aria-checked"), "false");
await overview.locator(".console-view-toggle-track").click();
assertEquals(await overview.getAttribute("aria-checked"), "true");
await details.focus();
await page.keyboard.press("Enter");
assertEquals(await details.getAttribute("aria-expanded"), "true");
await page.getByRole("button", { name: "Close", exact: true }).waitFor();
await page.keyboard.press("Space");
assertEquals(await details.getAttribute("aria-expanded"), "false");
await details.click();
assertEquals(await details.getAttribute("aria-expanded"), "true");
await page.getByRole("button", { name: "Close", exact: true }).click();
assertEquals(await details.getAttribute("aria-expanded"), "false");
if (mode === "both") {
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}.png`) });
}
const queue = mode === "both" || mode === "queue";
const notifications = ["both", "notifications", "legacy"].includes(mode);
assertEquals(
@@ -73,6 +73,7 @@ const subjects = [
42,
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
3,
connectedWorker("Release coordination Worker with a deliberately long display name"),
),
subject(emptySubjectId, "Empty ready subject", "active", 0),
subject(staleSubjectId, "Stale surface subject", "active", 19),
@@ -82,6 +83,30 @@ const subjects = [
];
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
function connectedWorker(displayName: string) {
return {
runtime_id: "embedded-worker-runtime",
worker_id: "release-coordination-worker",
host_id: "fixture-host",
display_name: displayName,
label: displayName,
profile: "builtin:companion",
singleton_key: `subjektiv:${representativeSubjectId}`,
tags: [],
workspace: {
visibility: "workspace",
identity: workspaceId,
workspace_id: workspaceId,
},
state: "idle",
last_seen_at: "2026-01-02T03:04:05Z",
pinned: false,
retention_state: "retained",
implementation: { kind: "embedded", display_hint: "Fixture Worker" },
diagnostics: [],
};
}
function subject(
id: string,
role: string,
@@ -89,6 +114,7 @@ function subject(
storeRevision: number,
behaviorMd = "",
behaviorRevision = 0,
currentWorker?: ReturnType<typeof connectedWorker>,
) {
return {
id,
@@ -99,6 +125,7 @@ function subject(
store_revision: storeRevision,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
...(currentWorker ? { current_worker: currentWorker } : {}),
};
}
@@ -141,7 +168,7 @@ function memoriesFor(subjectId: string) {
1,
"working_assumption",
"retracted",
"T-672 UNIQUE END MARKER",
"Memory list end marker for scroll validation",
),
];
}
@@ -251,7 +278,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) {
evidence_id: "evidence-message-0001",
origin: { kind: "human_input", account_id: "account-fixture" },
evidence_kind: "message",
label: "T-672 product direction",
label: "Memory information design source",
summary: "Bounded source reference retained with the committed revision.",
}],
source_refs_total: 1,
@@ -16,7 +16,7 @@ const memoryId =
const subjectPageCursor = "fixture-subject-page-2";
const createInteractionArtifacts = join(
repositoryRoot,
"target/web-ux/t-693-subject-behavior",
"target/web-ux/t-692-browser-interactions",
);
async function freePort(): Promise<number> {
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
const document = (globalThis as any).document;
const main = document.querySelector("main");
const marker = [...document.querySelectorAll("h3")].find((heading) =>
heading.textContent === "T-672 UNIQUE END MARKER"
heading.textContent === "Memory list end marker for scroll validation"
);
if (!main || !marker) return false;
const mainRect = main.getBoundingClientRect();
@@ -270,7 +270,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
assertEquals(
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
await page.getByRole("article", { name: "Resident context" }).locator("img, script")
.count(),
0,
);
@@ -290,7 +290,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
await page.locator("main").hover();
await page.mouse.wheel(0, 100_000);
} else if (scenario.label === "keyboard") {
await page.getByText("Surface provenance", { exact: true }).focus();
await page.getByText("Surface sources and diagnostics", { exact: true }).focus();
await page.keyboard.press("End");
} else {
await swipeMainToEnd(page);
@@ -321,17 +321,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
level: 1,
}).waitFor();
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
assertEquals(await detailPage.getByText("T-672 product direction").count(), 1);
assertEquals(await detailPage.getByText("Memory information design source").count(), 1);
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1);
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0);
assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
"link",
{ name: "Next page →" },
).click();
await detailPage.getByText("Revision 1", { exact: true }).waitFor();
await detailPage.getByText("Memory revision 1", { exact: true }).waitFor();
assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
assertEquals(
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
@@ -360,6 +360,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const paginationPage = await paginationContext.newPage();
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor();
assertEquals(
await paginationPage.getByText(
"Release coordination Worker with a deliberately long display name",
{ exact: true },
).count(),
1,
);
assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0);
assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0);
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
"link",
@@ -473,10 +482,12 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
1,
);
const currentWorker = createPage.locator(".subject-facts div").filter({
hasText: "Current worker",
});
assert((await currentWorker.textContent())?.includes("None"));
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
"..",
);
assert((await currentWorker.textContent())?.includes("Not connected"));
await createPage.getByText("Resident context has not been generated.").waitFor();
await createPage.getByText("No committed Memories yet.").waitFor();
await createPage.getByRole("button", { name: "Edit" }).click();
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
await behaviorEditor.fill(
@@ -519,21 +530,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const stateCases = [
{
id: "empty-subject",
text: "Resident surface is ready and empty.",
text: "Resident context is current but empty.",
role: "status" as const,
},
{
id: "stale-subject",
text: "Resident context needs to be refreshed.",
role: "status" as const,
},
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
{
id: "failed-subject",
text: "Resident surface generation failed.",
text: "Resident context generation failed.",
role: "alert" as const,
},
{
id: "ungenerated-subject",
text: "Resident surface has not been generated.",
text: "Resident context has not been generated.",
role: "status" as const,
},
{ id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const },
{
id: "error-subject",
text: "Resident context status unavailable.",
role: "alert" as const,
},
];
for (const stateCase of stateCases) {
const page = await stateContext.newPage();
@@ -541,8 +560,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
await state.waitFor();
assert((await state.textContent())?.includes(stateCase.text));
if (stateCase.id === "stale-subject") {
assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1);
}
if (stateCase.id === "failed-subject") {
assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1);
}
if (stateCase.id !== "empty-subject") {
assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0);
assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0);
}
await assertNoPageWideOverflow(page);
await page.close();
@@ -122,9 +122,33 @@
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
"ready": {
"kind": "selector",
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
},
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
"capturePoints": [
{ "id": "initial", "label": "Ready subject" },
{
"id": "subject-diagnostics",
"label": "Subject diagnostics",
"interaction": [
{ "action": "click", "selector": "details.subject-technical-details summary" }
],
"ready": {
"kind": "selector",
"selector": "details.subject-technical-details[open]"
}
},
{
"id": "surface-diagnostics",
"label": "Surface sources and diagnostics",
"interaction": [
{ "action": "click", "selector": "details.surface-details summary" }
],
"ready": {
"kind": "selector",
"selector": "details.surface-details[open]"
}
}
]
},
{
"id": "detail",
@@ -150,6 +174,18 @@
},
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
},
{
"id": "ungenerated",
"label": "Ungenerated subject context",
"path": "/w/memory-review/memory/ungenerated-subject",
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
"ready": {
"kind": "selector",
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
},
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
},
{
"id": "stale",
"label": "Stale subject surface",
@@ -122,9 +122,33 @@
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
"ready": {
"kind": "selector",
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
},
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
"capturePoints": [
{ "id": "initial", "label": "Ready subject" },
{
"id": "subject-diagnostics",
"label": "Subject diagnostics",
"interaction": [
{ "action": "click", "selector": "details.subject-technical-details summary" }
],
"ready": {
"kind": "selector",
"selector": "details.subject-technical-details[open]"
}
},
{
"id": "surface-diagnostics",
"label": "Surface sources and diagnostics",
"interaction": [
{ "action": "click", "selector": "details.surface-details summary" }
],
"ready": {
"kind": "selector",
"selector": "details.surface-details[open]"
}
}
]
},
{
"id": "detail",
@@ -150,6 +174,18 @@
},
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
},
{
"id": "ungenerated",
"label": "Ungenerated subject context",
"path": "/w/memory-review/memory/ungenerated-subject",
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
"ready": {
"kind": "selector",
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
},
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
},
{
"id": "stale",
"label": "Stale subject surface",
@@ -33,6 +33,7 @@ import {
parseWorkerLaunchOptionsResponse,
parseWorkerSummary,
} from "./workers.ts";
import { loadJson } from "./http.ts";
const worker = {
runtime_id: "runtime-a",
@@ -111,6 +112,92 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
assertEquals(observedStopped.state, "stopped");
});
const workerStates = [
{ kind: "idle" },
{ kind: "busy", state: { kind: "run", state: "running" } },
{ kind: "busy", state: { kind: "run", state: "pausing" } },
{ kind: "busy", state: { kind: "run", state: "paused" } },
{ kind: "busy", state: { kind: "run", state: "cancelling" } },
{ kind: "busy", state: { kind: "maintenance", state: "compacting" } },
] as const;
Deno.test("Worker summary parser preserves the optional submission completion fence in every state", () => {
for (const state of workerStates) {
for (const completion of [
{},
{ last_finished_submission_request_id: null },
{ last_finished_submission_request_id: "completed-submit" },
]) {
const snapshot = { last_command_id: 7, ...completion, state };
const parsed = parseWorkerSummary({
...worker,
resource_key: "W-1",
availability: "observed",
worker_state: snapshot,
});
assertEquals(parsed.worker_state, snapshot);
}
}
});
Deno.test("Worker summary parser validates submission completion fences without accepting unknown fields", () => {
for (const state of workerStates) {
for (const invalid of [0, false, {}, [], "x".repeat(65_537)]) {
assertThrows(
() => parseWorkerSummary({
...worker,
resource_key: "W-1",
availability: "observed",
worker_state: {
last_command_id: 7,
last_finished_submission_request_id: invalid,
state,
},
}),
Error,
"worker_state.last_finished_submission_request_id",
);
}
assertThrows(
() => parseWorkerSummary({
...worker,
resource_key: "W-1",
availability: "observed",
worker_state: {
last_command_id: 7,
last_finished_submission_request_id: "completed-submit",
state,
unexpected: true,
},
}),
Error,
"unknown field unexpected",
);
}
});
Deno.test("Console Worker API loading accepts a completed submission snapshot", async () => {
const snapshot = {
last_command_id: 0,
last_finished_submission_request_id: "completed-submit",
state: { kind: "idle" },
};
const result = await loadJson(
() => Promise.resolve(Response.json({
...worker,
resource_key: "W-1",
availability: "observed",
worker_state: snapshot,
})),
"/api/w/workspace-a/workers/W-1",
undefined,
parseWorkerSummary,
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
);
assertEquals(result.error, null);
assertEquals(result.data?.worker_state, snapshot);
});
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
const attachment = {
alias: "sessions",
+12 -1
View File
@@ -262,7 +262,16 @@ function workerStateSnapshot(
label: string,
): WorkerStateSnapshot {
const snapshot = record(value, label);
exact(snapshot, ["last_command_id", "state"], label);
exact(
snapshot,
["last_command_id", "last_finished_submission_request_id", "state"],
label,
);
const lastFinishedSubmissionRequestId = optional(
snapshot.last_finished_submission_request_id,
`${label}.last_finished_submission_request_id`,
string,
);
const state = record(snapshot.state, `${label}.state`);
const kind = string(state.kind, `${label}.state.kind`);
if (kind === "idle") {
@@ -272,6 +281,7 @@ function workerStateSnapshot(
snapshot.last_command_id,
`${label}.last_command_id`,
),
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
state: { kind: "idle" },
};
}
@@ -291,6 +301,7 @@ function workerStateSnapshot(
snapshot.last_command_id,
`${label}.last_command_id`,
),
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
state: {
kind: "busy",
state: busyKind === "run"
@@ -0,0 +1,231 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
const toolchain = vi.hoisted(() => ({
construct: vi.fn(),
setSnapshot: vi.fn(),
analyze: vi.fn(),
complete: vi.fn(),
close: vi.fn(),
}));
vi.mock("./toolchain.ts", () => ({
ConfigSourceToolchain: class {
constructor() {
toolchain.construct();
}
setSnapshot = toolchain.setSnapshot;
analyze = toolchain.analyze;
complete = toolchain.complete;
close = toolchain.close;
},
}));
const schemaSource = "schema body\n".repeat(6000);
const tree = {
snapshot: {
revision: 7,
digest: "sha256:tree",
entries: {
"main.dcdl": {
path: "main.dcdl",
content_type: "decodal",
content: "WorkspaceConfigSchema({})\n",
content_digest: "sha256:main",
},
},
},
contract: {
contract_version: 1,
decodal_version: "0.4.0",
schema_version: 1,
entrypoints: ["main.dcdl"],
import_policy_version: 1,
schema_bundle: {
contributions: [{
provider_id: "builtin",
namespace: "workspace",
version: "1",
source: schemaSource,
source_digest: "sha256:contribution",
}],
source: schemaSource,
fingerprint: "sha256:schema",
},
fingerprint: "sha256:toolchain",
},
projection_digest: "sha256:projection",
};
const fetcher = vi.fn();
const response = () => new Response(JSON.stringify(tree));
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", fetcher);
fetcher.mockImplementation(async () => response());
toolchain.setSnapshot.mockResolvedValue(undefined);
toolchain.analyze.mockResolvedValue([]);
toolchain.complete.mockResolvedValue(null);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function renderEditor(workspaceId = "w") {
return render(ConfigSourceEditor, { workspaceId });
}
function expectNoEditor(view: ReturnType<typeof renderEditor>) {
expect(view.queryByText("Select or create a source")).toBeNull();
expect(view.queryByRole("button", { name: "Create local source" }))
.toBeNull();
expect(view.queryByRole("button", { name: "Commit" })).toBeNull();
expect(view.container.querySelector(".cm-editor")).toBeNull();
}
async function expectReady(view: ReturnType<typeof renderEditor>) {
await waitFor(() =>
expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not
.toBeNull()
);
expect(view.container.querySelector('.cm-content[contenteditable="true"]'))
.not.toBeNull();
expect(view.container.textContent).toContain("WorkspaceConfigSchema({})");
expect(view.queryByRole("alert")).toBeNull();
}
test("loading is not an empty source tree; editing waits for schema installation", async () => {
let finishFetch!: (response: Response) => void;
let finishSetup!: () => void;
fetcher.mockReturnValueOnce(
new Promise<Response>((resolve) => {
finishFetch = resolve;
}),
);
toolchain.setSnapshot.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishSetup = resolve;
}),
);
const view = render(ConfigSourceEditor, { workspaceId: "w/one" });
expect(view.getByRole("status").textContent).toContain("Loading source tree");
expectNoEditor(view);
finishFetch(response());
await waitFor(() =>
expect(toolchain.setSnapshot).toHaveBeenCalledWith(
tree.snapshot,
tree.contract.schema_bundle,
)
);
expectNoEditor(view);
finishSetup();
await expectReady(view);
expect(fetcher).toHaveBeenCalledWith(
"/api/w/w%2Fone/config/source-tree",
expect.anything(),
);
});
test.each(["http", "invalid"])(
"%s failure has a manual reload, not creation controls or automatic retries",
async (failure) => {
fetcher.mockResolvedValueOnce(
failure === "http"
? new Response("Backend unavailable", { status: 503 })
: new Response(JSON.stringify({ ...tree, unexpected: true })),
);
const view = renderEditor();
await waitFor(() =>
expect(view.getByRole("alert").textContent).toContain(
"Unable to load workspace configuration",
)
);
expect(view.getByRole("alert").textContent).toContain(
failure === "http" ? "Backend unavailable" : "invalid response",
);
expectNoEditor(view);
expect(toolchain.setSnapshot).not.toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 300));
expect(fetcher).toHaveBeenCalledOnce();
await fireEvent.click(
view.getByRole("button", { name: "Reload source tree" }),
);
await expectReady(view);
expect(fetcher).toHaveBeenCalledTimes(2);
},
);
test.each(["construct", "setSnapshot"] as const)(
"toolchain %s failure is caught and manual reload initializes a fresh toolchain",
async (phase) => {
if (phase === "construct") {
toolchain.construct.mockImplementationOnce(() => {
throw new Error("Worker unavailable");
});
} else {toolchain.setSnapshot.mockRejectedValueOnce(
new Error("Schema setup failed"),
);}
const view = renderEditor();
await waitFor(() =>
expect(view.getByRole("alert").textContent).toContain(
phase === "construct" ? "Worker unavailable" : "Schema setup failed",
)
);
expectNoEditor(view);
await fireEvent.click(
view.getByRole("button", { name: "Reload source tree" }),
);
await expectReady(view);
expect(toolchain.construct).toHaveBeenCalledTimes(2);
},
);
test("a successfully loaded empty tree still offers source creation", async () => {
fetcher.mockResolvedValueOnce(
new Response(JSON.stringify({
...tree,
snapshot: { ...tree.snapshot, entries: {} },
})),
);
const view = renderEditor();
await waitFor(() =>
expect(view.getByText("Select or create a source")).not.toBeNull()
);
expect(view.getByRole("button", { name: "Create local source" })).not
.toBeNull();
expect(view.queryByRole("alert")).toBeNull();
});
test("unmount during schema setup closes the toolchain", async () => {
let failSetup!: (error: Error) => void;
toolchain.setSnapshot.mockReturnValueOnce(
new Promise<void>((_, reject) => {
failSetup = reject;
}),
);
toolchain.close.mockImplementationOnce(() =>
failSetup(new Error("toolchain closed"))
);
const view = renderEditor();
await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce());
view.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(toolchain.close).toHaveBeenCalledOnce();
});
test("unmount during fetch does not start a toolchain", async () => {
let finishFetch!: (response: Response) => void;
fetcher.mockReturnValueOnce(
new Promise<Response>((resolve) => {
finishFetch = resolve;
}),
);
const view = renderEditor();
view.unmount();
finishFetch(response());
await new Promise((resolve) => setTimeout(resolve, 0));
expect(toolchain.construct).not.toHaveBeenCalled();
});
@@ -21,6 +21,8 @@
let newPath = $state("module.dcdl");
let diagnostics = $state<ConfigDiagnostic[]>([]);
let status = $state("Loading source tree…");
let loadState = $state<"loading" | "error" | "ready">("loading");
let mounted = false;
let busy = $state(false);
let workingChanges = $state<ConfigTreeChange[]>([]);
let baseRevision = $state(0);
@@ -42,9 +44,12 @@
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
onMount(() => {
toolchain = new ConfigSourceToolchain();
mounted = true;
void reload();
return () => toolchain?.close();
return () => {
mounted = false;
toolchain?.close();
};
});
$effect(() => {
@@ -70,9 +75,20 @@
});
async function reload() {
loadState = "loading";
status = "Loading source tree…";
analysisReady = false;
analysisGeneration += 1;
try {
treeState = await fetchConfigTree(workspaceId);
const remote = await fetchConfigTree(workspaceId);
if (!mounted) return;
toolchain?.close();
toolchain = null;
toolchain = new ConfigSourceToolchain();
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
if (!mounted) return;
// Publish the new baseline only after both fetch and toolchain setup succeed.
treeState = remote;
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
}
@@ -80,15 +96,19 @@
baseSnapshot = $state.snapshot(treeState.snapshot);
baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
analysisReady = true;
workingChanges = [];
renamePath = selectedPath;
diagnostics = [];
conflict = false;
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
loadState = "ready";
} catch (error) {
if (!mounted) return;
toolchain?.close();
toolchain = null;
status = String(error);
loadState = "error";
}
}
@@ -213,8 +233,6 @@
}
async function discardAndReload() {
workingChanges = [];
source = "";
await reload();
}
@@ -287,7 +305,18 @@
}
</script>
<section class="config-source-shell" aria-label="Workspace configuration source tree">
<section class="config-source-shell" aria-label="Workspace configuration source tree" aria-busy={loadState === "loading"}>
{#if loadState === "loading"}
<div class="config-source-load" role="status">Loading source tree…</div>
{:else if loadState === "error"}
<div class="config-source-load">
<div role="alert">
<strong>Unable to load workspace configuration</strong>
<p>{status}</p>
</div>
<button type="button" onclick={reload}>Reload source tree</button>
</div>
{:else}
<aside class="config-source-tree">
<div class="config-source-tree__header">
<strong>Source tree</strong>
@@ -354,4 +383,12 @@
</ol>
{/if}
</div>
{/if}
</section>
<style>
.config-source-load {
grid-column: 1 / -1;
padding: 1rem;
}
</style>
@@ -11,6 +11,9 @@ import type {
} from "./types.ts";
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// Schema bodies are documents, not short metadata. Keep them within the
// response budget; readJson also bounds the combined bundle and contributions.
const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES;
const MAX_ERROR_BYTES = 4 * 1024;
const MAX_ENTRY_COUNT = 256;
const MAX_ENTRY_BYTES = 256 * 1024;
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
provider_id: boundedString(item.provider_id),
namespace: boundedString(item.namespace),
version: boundedString(item.version),
source: boundedString(item.source),
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
...(projection === undefined ? {} : { projection_validator: projection }),
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
};
@@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle {
const item = record(value, ["contributions", "source", "fingerprint"]);
return {
contributions: boundedArray(item.contributions).map(parseContribution),
source: boundedString(item.source),
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
};
}
@@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte";
afterEach(cleanup);
test("the three-dot Details control invokes the existing toggle and reflects its state", async () => {
let toggles = 0;
const view = render(WorkerContextStatus, {
metadata: null,
detailsOpen: false,
onToggleDetails: () => { toggles++; },
});
const button = view.getByRole("button", { name: "Details" });
expect(button.querySelectorAll("circle")).toHaveLength(3);
expect(button.textContent?.trim()).toBe("");
expect(button.getAttribute("aria-expanded")).toBe("false");
button.click();
expect(toggles).toBe(1);
await view.rerender({ detailsOpen: true });
expect(button.getAttribute("aria-expanded")).toBe("true");
});
test("renders resolved model, reasoning, and measured context accessibly", () => {
const view = render(WorkerContextStatus, {
metadata: {
@@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () =>
const status = view.getByLabelText("Worker model and context");
expect(status.textContent).toContain("gpt-6-astra · high");
expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)");
expect(status.textContent).toContain("142.0k / 272.0k (52%)");
});
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
const view = render(WorkerContextStatus, { metadata: null });
expect(view.getByText("Model unavailable")).not.toBeNull();
expect(view.getByText("Context unavailable")).not.toBeNull();
expect(view.getByText("unavailable")).not.toBeNull();
expect(view.container.textContent).not.toContain("0%");
await view.rerender({
@@ -38,7 +55,7 @@ test("renders honest unavailable state instead of a fabricated zero percent", as
},
});
expect(view.container.textContent).toContain("claude · 8.2k token budget");
expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)");
expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)");
await view.rerender({
metadata: {
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ConsoleWorkerMetadata } from "./model";
import {
formatContextSummary,
@@ -7,23 +8,51 @@
type Props = {
metadata: ConsoleWorkerMetadata | null;
controls?: Snippet;
detailsOpen?: boolean;
onToggleDetails?: () => void;
};
let { metadata }: Props = $props();
let { metadata, controls, detailsOpen = false, onToggleDetails }: Props = $props();
const modelSummary = $derived(formatModelSummary(metadata));
const contextSummary = $derived(formatContextSummary(metadata));
</script>
<div class="worker-context-status" aria-label="Worker model and context">
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
<span class="separator" aria-hidden="true">|</span>
<span class="separator" aria-hidden="true">·</span>
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
{#if controls || onToggleDetails}
<div class="worker-context-actions">
{@render controls?.()}
{#if onToggleDetails}
<button
type="button"
class="details-button"
aria-label="Details"
aria-expanded={detailsOpen}
onclick={onToggleDetails}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{/if}
</div>
{/if}
</div>
<style>
.worker-context-status {
display: flex;
flex: 0 0 auto;
flex-wrap: nowrap;
min-width: 0;
overflow: hidden;
white-space: nowrap;
padding-inline: var(--space-3);
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
@@ -50,20 +79,44 @@
color: var(--line-strong);
}
@media (max-width: 640px) {
.worker-context-status {
width: 100%;
flex-wrap: wrap;
row-gap: 0;
}
.worker-context-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.separator {
display: none;
}
.details-button {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: var(--space-5);
height: var(--space-5);
padding: 0;
border: 0;
border-radius: var(--space-1);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.worker-model,
.worker-context {
flex: 1 1 100%;
}
.details-button:hover,
.details-button[aria-expanded="true"] {
background: var(--bg-subtle);
color: var(--text-strong);
}
.details-button:focus-visible {
color: var(--text-strong);
outline: 1px solid currentColor;
outline-offset: -2px;
}
.details-button svg {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
@@ -3769,6 +3769,29 @@ Deno.test("committed tool identities reconcile one live call block", () => {
);
});
Deno.test("image rejection correction updates the existing tool result without duplicate rows", () => {
const result: SessionSnapshotEntry = {
kind: "tool_result", entry_id: "result-image", timestamp: 2,
provenance: "tool_output", call_id: "image", summary: "Attached image", is_error: false,
attachments: [{ attachment_id: "image-body", media_type: "image/png", byte_len: 100 }],
};
const lines = projectConsole([
{ eventId: "call", event: { event: "session_entry_committed", data: { entry: {
kind: "tool_call", entry_id: "call-image", timestamp: 1, provenance: "model_output",
call_id: "image", name: "ViewImage", arguments: "{}",
} } } },
{ eventId: "original", event: { event: "session_entry_committed", data: { entry: result } } },
{ eventId: "correction", event: { event: "session_entry_committed", data: { entry: {
...result, timestamp: 3, summary: "Resize or crop and retry ViewImage", is_error: true, attachments: [],
} } } },
]).lines;
assertEquals(lines.length, 1);
assertEquals(lines[0].entryId, "result-image");
assertEquals(lines[0].toolCall?.state, "error");
assertEquals(lines[0].attachments ?? [], []);
assert(lines[0].body.includes("Resize or crop"), "show the model recovery instruction");
});
function readHistory(ids: string[], summary: string): ConsoleLine[] {
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1,
@@ -45,7 +45,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
contextTokens: 142_000,
contextSource: "estimated",
}),
"Context ~142.0k / 272.0k (52%)",
"~142.0k / 272.0k (52%)",
);
assertEquals(formatModelSummary(null), "Model unavailable");
assertEquals(
@@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
"reasoning unavailable",
);
assertEquals(formatContextSummary(null), "Context unavailable");
assertEquals(formatContextSummary(null), "unavailable");
assertEquals(
formatContextSummary({
model: null,
@@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
contextTokens: 0,
contextSource: null,
}),
"Context unavailable",
"unavailable",
);
});
@@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
subjectIndexPage.includes(">Subjects</h1>") &&
subjectIndexPage.includes("subject.store_revision") &&
subjectIndexPage.includes("Worker connection") &&
subjectIndexPage.includes("subject.current_worker") &&
!subjectIndexPage.includes("subject.store_revision") &&
subjectIndexPage.includes('data-memory-view="subjects"'),
"Memory product entry should list explicit typed subjects",
);
@@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
subjectPageLoad.includes("`${subjectPath}/surface`") &&
subjectPageLoad.includes("`${subjectPath}/memories`") &&
subjectPage.includes("DocumentMarkdown from") &&
subjectPage.includes("Resident surface") &&
subjectPage.includes("Resident context") &&
subjectPage.includes("Subject store revision") &&
subjectPage.includes("Current Memories") &&
subjectPage.includes("surface?.availability === 'ready'") &&
subjectPage.includes("surface?.availability === 'ungenerated'") &&
subjectPage.includes("surface?.availability === 'stale'") &&
subjectPage.includes("surface?.availability === 'failed'") &&
subjectPage.includes("data-surface-ready-empty") &&
!subjectPage.includes("overflow-y"),
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
@@ -738,6 +743,32 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
);
});
Deno.test("Model information stays below Composer in one compact monospace row", async () => {
const page = await Deno.readTextFile(new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url,
));
const composer = page.indexOf('<form class="console-composer"');
const composerEnd = page.indexOf("</form>", composer);
const status = page.indexOf("<WorkerContextStatus");
assert(composer >= 0 && composerEnd > composer && status > composerEnd, "Model information must follow Composer");
assert(page.includes("metadata={selectedConsoleProjection.workerMetadata}"), "Keep selected Worker metadata");
assert(page.indexOf("<WorkerContextStatus", status + 1) === -1, "Render model information only once");
const component = await Deno.readTextFile(new URL("./WorkerContextStatus.svelte", import.meta.url));
for (const rule of [
"flex-wrap: nowrap", "white-space: nowrap", "text-overflow: ellipsis",
"font-family: var(--font-mono)", "font-size: var(--font-size-compact)",
"line-height: var(--line-height-compact)", "padding-inline: var(--space-3)",
]) assert(component.includes(rule), rule);
assert(!component.includes("@media"), "Narrow layouts must retain the same single row");
const modes = page.indexOf('class="console-view-toggle"');
assert(modes > status && modes < page.indexOf("</WorkerContextStatus>", status), "Display toggle must be in the model information row");
assert(!page.includes("console-header"), "Remove the empty header and its styles");
assert(component.indexOf("{@render controls?.()}") < component.indexOf('class="details-button"'), "Display controls must precede Details");
assert(/\.worker-context-actions\s*\{[^}]*flex: 0 0 auto/.test(component), "Keep display controls from shrinking on narrow screens");
assert(component.includes("margin-left: auto"), "Align footer controls to the right");
});
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
const files = [
"ComposerInput.svelte",
@@ -1551,14 +1582,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
const input = await Deno.readTextFile(
new URL("./ComposerInput.svelte", import.meta.url),
);
const header = page.slice(
page.indexOf('<section class="console-header'),
page.indexOf("{#if rewindTargets.length"),
);
assert(
!header.includes("Compact") && !header.includes("Rewind") &&
!page.includes('class="console-header') &&
!page.includes("requestRewindTargets"),
"Header must defer Compact/Rewind to commands",
"Compact/Rewind must remain commands without a dedicated header",
);
assert(
input.includes('role="listbox"') && input.includes('role="option"') &&
@@ -1588,12 +1615,8 @@ Deno.test("mini task summary owns pane toggling instead of the header", async ()
const component = await Deno.readTextFile(
new URL("./ConsoleTasks.svelte", import.meta.url),
);
const header = page.slice(
page.indexOf('<section class="console-header'),
page.indexOf("{#if rewindTargets.length"),
);
assert(
!header.includes("taskPaneOpen = !taskPaneOpen") &&
!page.includes('class="console-header') &&
page.includes("onTogglePane={() => {"),
"Only the mini summary should own Tasks toggling",
);
@@ -36,11 +36,11 @@ export function formatContextSummary(
used === null || used === undefined || limit === null ||
limit === undefined || !Number.isSafeInteger(used) ||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
) return "Context unavailable";
) return "unavailable";
const percent = Math.round((used / limit) * 100);
if (!Number.isFinite(percent)) return "Context unavailable";
if (!Number.isFinite(percent)) return "unavailable";
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
return `Context ${estimate}${formatRunTokens(used)} / ${
return `${estimate}${formatRunTokens(used)} / ${
formatRunTokens(limit)
} (${percent}%)`;
}
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import {
type CompletionResult,
completionStatus,
startCompletion,
} from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import DecodalSourceEditor from "./DecodalSourceEditor.svelte";
afterEach(cleanup);
function setup(
value: string,
props: {
readonly?: boolean;
fixedSchemaWrapper?: boolean;
onComplete?: (
source: string,
offset: number,
explicit: boolean,
) => Promise<CompletionResult | null>;
} = {},
) {
const onChange = vi.fn();
const result = render(DecodalSourceEditor, { value, onChange, ...props });
const content = result.container.querySelector<HTMLElement>(".cm-content")!;
const editor = EditorView.findFromDOM(content)!;
editor.focus();
return { ...result, content, editor, onChange };
}
test.each([
{ name: "plain line", value: "name = 1", anchor: 8, expected: "name = 1\n" },
{
name: "object body",
value: "{\n name = 1",
anchor: 12,
expected: "{\n name = 1\n ",
},
{ name: "empty object", value: "{}", anchor: 1, expected: "{\n \n}" },
{
name: "nested object",
value: "{\n nested = {}\n}",
anchor: 14,
expected: "{\n nested = {\n \n }\n}",
},
])(
"Enter inserts an indented newline in $name",
async ({ value, anchor, expected }) => {
const { content, editor, onChange } = setup(value);
editor.dispatch({ selection: { anchor } });
expect(await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }))
.toBe(false);
expect(editor.state.doc.toString()).toBe(expected);
expect(onChange).toHaveBeenLastCalledWith(expected);
},
);
test("Enter indents inside the fixed wrapper without altering its assertion", async () => {
const { content, editor } = setup("{} as WorkspaceConfigSchema\n", {
fixedSchemaWrapper: true,
});
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
expect(editor.state.doc.toString()).toBe(
"{\n \n} as WorkspaceConfigSchema\n",
);
expect(editor.state.selection.main.head).toBe(4);
});
test("Enter does not mutate readonly sources", async () => {
const value = "{\n name = 1";
const { content, editor, onChange } = setup(value, { readonly: true });
editor.dispatch({ selection: { anchor: value.length } });
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
expect(editor.state.doc.toString()).toBe(value);
expect(onChange).not.toHaveBeenCalled();
});
test("Enter adds an indented newline instead of accepting a visible completion", async () => {
const value = "{\n na";
const { content, editor } = setup(value, {
onComplete: async () => ({
from: 4,
options: [{ label: "name", type: "property" }],
}),
});
editor.dispatch({ selection: { anchor: value.length } });
startCompletion(editor);
await waitFor(() => expect(completionStatus(editor.state)).toBe("active"));
// Wait past CodeMirror's completion interaction delay, so accepting a
// suggestion would be observable rather than ignored as an early keypress.
await new Promise((resolve) => setTimeout(resolve, 100));
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
expect(editor.state.doc.toString()).toBe("{\n na\n ");
await waitFor(() => expect(completionStatus(editor.state)).toBeNull());
});
test("Tab indents the current line and Shift+Tab removes indentation", async () => {
const { content, editor, onChange } = setup("name = 1");
editor.dispatch({ selection: { anchor: 4 } });
expect(await fireEvent.keyDown(content, { key: "Tab", code: "Tab" })).toBe(
false,
);
expect(editor.state.doc.toString()).toBe(" name = 1");
expect(onChange).toHaveBeenLastCalledWith(" name = 1");
expect(editor.hasFocus).toBe(true);
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
expect(editor.state.doc.toString()).toBe("name = 1");
expect(onChange).toHaveBeenLastCalledWith("name = 1");
});
test("Tab and Shift+Tab indent every selected line", async () => {
const value = "first = 1\nsecond = 2";
const { content, editor } = setup(value);
editor.dispatch({ selection: { anchor: 0, head: value.length } });
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
expect(editor.state.doc.toString()).toBe(" first = 1\n second = 2");
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
expect(editor.state.doc.toString()).toBe(value);
});
test("Tab inserts indentation on a blank line", async () => {
const { content, editor } = setup("");
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
expect(editor.state.doc.toString()).toBe(" ");
});
test("Tab and Shift+Tab do not mutate readonly sources", async () => {
const value = " name = 1";
const { content, editor, onChange } = setup(value, { readonly: true });
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
expect(editor.state.doc.toString()).toBe(value);
expect(onChange).not.toHaveBeenCalled();
});
test("Tab indents the schema body without modifying the fixed wrapper", async () => {
const value = "{\n name = 1\n} as WorkspaceConfigSchema\n";
const { content, editor } = setup(value, { fixedSchemaWrapper: true });
editor.dispatch({ selection: { anchor: value.indexOf("name") } });
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
expect(editor.state.doc.toString()).toBe(
"{\n name = 1\n} as WorkspaceConfigSchema\n",
);
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
expect(editor.state.doc.toString()).toBe(value);
editor.dispatch({ selection: { anchor: 0 } });
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
expect(editor.state.doc.toString()).toBe(value);
});
@@ -9,6 +9,7 @@
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete';
import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
@@ -129,6 +130,8 @@
decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme),
autocompletion({
// Use the explicit keymap below so Enter never accepts a suggestion.
defaultKeymap: false,
activateOnTyping: false,
override: [
async (context: CompletionContext) => {
@@ -139,6 +142,10 @@
],
}),
keymap.of(completionKeymapWithoutEnter),
keymap.of([
{ key: 'Enter', run: insertNewlineAndIndent },
indentWithTab,
]),
fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
),
@@ -1,5 +1,6 @@
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { on } from 'svelte/events';
import Bevel from '#lib/workspace/ui/Bevel.svelte';
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
import './sidebar.css';
@@ -77,6 +78,19 @@
focusInside = false;
clearCloseTimer();
});
function closeAfterNavigation(event: MouseEvent) {
if (!mobile || !open || event.defaultPrevented || event.button !== 0 ||
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!(link instanceof HTMLAnchorElement) || !frame.contains(link) ||
link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
clearCloseTimer();
onOpenChange(false);
}
// Handle nested navigation links, including activation of the current page.
// Svelte's listener preserves ordering with delegated child click handlers.
onMount(() => on(frame, 'click', closeAfterNavigation));
onDestroy(clearCloseTimer);
</script>
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { createRawSnippet } from "svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import SidebarFrame from "./SidebarFrame.svelte";
// Stop happy-dom navigation after the component has handled the bubbling click.
const preventBrowserNavigation = (event: MouseEvent) => event.preventDefault();
beforeEach(() => document.addEventListener("click", preventBrowserNavigation));
afterEach(() => {
cleanup();
document.removeEventListener("click", preventBrowserNavigation);
});
function mount(mobile = true, mode: "pinned" | "hover" = "pinned") {
const onOpenChange = vi.fn();
const onModeChange = vi.fn();
const children = createRawSnippet(() => ({
render: () => `<nav>
<a href="/w/example"><span>Workspace home</span></a>
<a href="${location.href}">Current page</a>
<a href="#section">Section</a>
<a href="/account" target="_blank">New tab</a>
<a href="/export" download>Download</a>
<button type="button">Expand group</button>
</nav>`,
}));
return { ...render(SidebarFrame, {
mobile, mode, open: true, children, onOpenChange, onModeChange,
}), onOpenChange, onModeChange };
}
test.each(["pinned", "hover"] as const)("mobile %s closes on nested, current-page and hash links", async (mode) => {
const view = mount(true, mode);
for (const label of ["Workspace home", "Current page", "Section"]) {
view.onOpenChange.mockClear();
await fireEvent.click(view.getByText(label));
expect(view.onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
expect(view.onModeChange).not.toHaveBeenCalled();
}
});
test("mobile ignores disclosure buttons, modified clicks, new tabs, downloads and cancelled navigation", async () => {
const view = mount();
for (const label of ["Expand group", "New tab", "Download"]) {
await fireEvent.click(view.getByText(label));
}
const link = view.getByText("Workspace home");
for (const options of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
await fireEvent.click(link, options);
}
link.addEventListener("click", (event) => event.preventDefault(), { once: true });
await fireEvent.click(link);
expect(view.onOpenChange).not.toHaveBeenCalled();
});
test.each(["pinned", "hover"] as const)("desktop %s navigation does not change sidebar mode or open state", async (mode) => {
const view = mount(false, mode);
await fireEvent.click(view.getByText("Workspace home"));
expect(view.onOpenChange).not.toHaveBeenCalled();
expect(view.onModeChange).not.toHaveBeenCalled();
});
@@ -34,6 +34,10 @@
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
}
async function openCreateForm(): Promise<void> {
createExpanded = true;
createError = null;
@@ -121,7 +125,7 @@
</div>
<div class="memory-header-actions">
{#if data.subjects.data}
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
{/if}
<button
bind:this={createButton}
@@ -209,14 +213,19 @@
{#each subjects as subject (subject.id)}
<a class="subject-row" href={subjectHref(subject.id)}>
<div class="subject-copy">
<span class="memory-state-pill is-{subject.state}">{subject.state}</span>
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
<h2>{subject.role}</h2>
<code title={subject.id}>{subject.id}</code>
</div>
<dl class="subject-meta">
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
<div>
<dt>Worker connection</dt>
<dd>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
</dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
@@ -465,8 +474,8 @@
.subject-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
align-self: center;
}
@@ -480,12 +489,19 @@
}
.subject-meta dd {
display: grid;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.subject-meta dd strong {
color: var(--text);
font-weight: 700;
}
.memory-state {
padding: var(--space-5) 0;
color: var(--text-muted);
@@ -6,7 +6,7 @@
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -116,6 +116,24 @@
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
if (availability === 'ungenerated') return 'Not generated';
if (availability === 'stale') return 'Needs refresh';
return 'Generation failed';
}
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
if (availability !== 'ready') return 'No current generated context is available to use.';
return snapshot?.body_md.trim()
? 'Current generated context is available.'
: 'A current empty surface exists; there is no generated Memory context to show.';
}
function subjectStateLabel(state: 'active' | 'retired'): string {
return state === 'active' ? 'Available' : 'Retired';
}
</script>
<svelte:head>
@@ -136,13 +154,50 @@
</header>
{#if subject}
<dl class="subject-facts" aria-label="Subject details">
<div><dt>Role</dt><dd>{subject.role}</dd></div>
<div><dt>State</dt><dd><span class="subject-state is-{subject.state}">{subject.state}</span></dd></div>
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
<div><dt>Current worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
</dl>
<section class="subject-overview" aria-label="Subject status">
<div>
<span>Subject status</span>
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<div>
<span>Worker connection</span>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div>
<div>
<span>Committed Memories</span>
{#if data.memories.data}
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
{:else}
<strong>Unavailable</strong>
<p>The current Memory list could not be read.</p>
{/if}
</div>
<div>
<span>Generated Memory context</span>
{#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p>
{:else}
<strong>Unavailable</strong>
<p>The resident context status could not be read.</p>
{/if}
</div>
</section>
<details class="subject-technical-details">
<summary>Technical details</summary>
<dl>
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
<div>
<dt>Subject store revision</dt>
<dd>{subject.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</details>
{:else if data.subject.error}
<div class="memory-state is-error" role="alert">
<strong>Subject details unavailable.</strong>
@@ -189,38 +244,40 @@
<section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Resident context</p>
<h2 id="resident-surface-heading">Resident surface</h2>
<p class="memory-eyebrow">Generated from committed Memory</p>
<h2 id="resident-surface-heading">Resident context</h2>
</div>
{#if surface}
<span class="availability is-{surface.availability}">{surface.availability}</span>
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
{/if}
</header>
{#if surface?.availability === 'ready' && snapshot}
<div class="surface-meta">
<span>Built from store revision {snapshot.built_from_store_revision}</span>
<span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
<span aria-hidden="true">·</span>
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
<span aria-hidden="true">·</span>
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
</div>
{#if snapshot.body_md.trim().length === 0}
<div class="memory-state" role="status" data-surface-ready-empty>
<strong>Resident surface is ready and empty.</strong>
<p>This subject currently has no resident context to display.</p>
<strong>Resident context is current but empty.</strong>
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
</div>
{:else}
<article class="surface-document" aria-label="Resident surface">
<article class="surface-document" aria-label="Resident context">
<DocumentMarkdown text={snapshot.body_md} />
</article>
{/if}
<details class="surface-details">
<summary>Surface provenance</summary>
<summary>Surface sources and diagnostics</summary>
<dl>
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div>
<dt>Memory refs</dt>
<dt>Generated from Subject revision</dt>
<dd>{snapshot.built_from_store_revision}<small>The Subject change number used for this surface, distinct from each Memory’s revision.</small></dd>
</div>
<div>
<dt>Referenced Memory revisions</dt>
<dd>
{#if snapshot.memory_refs.length === 0}
None
@@ -237,26 +294,26 @@
</details>
{:else if surface?.availability === 'ungenerated'}
<div class="memory-state" role="status">
<strong>Resident surface has not been generated.</strong>
<p>Committed Memories remain available below.</p>
<strong>Resident context has not been generated.</strong>
<p>Committed Memories, if any, remain available below.</p>
</div>
{:else if surface?.availability === 'stale'}
<div class="memory-state is-warning" role="status">
<strong>Resident surface is stale.</strong>
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
<strong>Resident context needs to be refreshed.</strong>
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
</div>
{:else if surface?.availability === 'failed'}
<div class="memory-state is-error" role="alert">
<strong>Resident surface generation failed.</strong>
<p>No failed or partial snapshot is displayed.</p>
<strong>Resident context generation failed.</strong>
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
</div>
{:else if data.surface.error}
<div class="memory-state is-error" role="alert">
<strong>Resident surface unavailable.</strong>
<strong>Resident context status unavailable.</strong>
<p>{data.surface.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
{/if}
</section>
@@ -266,14 +323,14 @@
<p class="memory-eyebrow">Committed records</p>
<h2 id="current-memories-heading">Current Memories</h2>
</div>
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if}
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
{#if data.memories.data}
{#if memories.length === 0}
<div class="memory-state" role="status" data-memories-empty>
<strong>No committed Memories.</strong>
<p>This subject has no current Memory revisions.</p>
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
@@ -289,9 +346,9 @@
<p>{memory.excerpt || 'No excerpt.'}</p>
</div>
<dl>
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Revision</dt><dd>{memory.revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
@@ -384,25 +441,79 @@
font-size: var(--font-size-compact);
}
.subject-facts {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
.subject-overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-facts > div {
display: block;
.subject-overview > div {
display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0;
}
.subject-facts dt {
white-space: normal;
.subject-overview span,
.subject-overview p,
.subject-technical-details summary,
.subject-technical-details small,
.surface-details small {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-facts dd {
margin-top: var(--space-1);
color: var(--text);
.subject-overview strong {
color: var(--text-strong);
overflow-wrap: anywhere;
}
.subject-overview p {
margin: 0;
overflow-wrap: anywhere;
}
.subject-technical-details {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.subject-technical-details summary {
width: fit-content;
cursor: pointer;
font-weight: 700;
}
.subject-technical-details dl,
.surface-details dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-3);
}
.subject-technical-details dl > div,
.surface-details dl > div {
display: block;
min-width: 0;
}
.subject-technical-details dd,
.surface-details dd {
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.subject-technical-details small,
.surface-details small {
display: block;
margin-top: var(--space-1);
line-height: 1.4;
}
.subject-state,
.availability,
.memory-pill,
@@ -642,7 +753,7 @@
}
@media (max-width: 900px) {
.subject-facts {
.subject-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -659,10 +770,16 @@
gap: var(--space-2);
}
.subject-facts {
.subject-overview,
.subject-technical-details dl,
.surface-details dl {
grid-template-columns: minmax(0, 1fr);
}
.subject-overview {
padding: var(--space-3);
}
.memory-row {
padding-inline: 0;
}
@@ -63,6 +63,11 @@
return value.replaceAll('_', ' ');
}
function titleLabel(value: string): string {
const normalized = label(value);
return normalized[0].toUpperCase() + normalized.slice(1);
}
function rangeLabel(range: [number, number] | null | undefined): string {
return range ? `${range[0]}–${range[1]}` : 'None';
}
@@ -98,8 +103,8 @@
<header class="memory-page-header">
<div>
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{memory.state}</span>
<span class="memory-kind">{label(memory.kind)}</span>
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
<span class="memory-kind">{titleLabel(memory.kind)}</span>
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
</div>
<h1 id="memory-detail-heading">{memory.claim}</h1>
@@ -109,11 +114,11 @@
</header>
<dl class="memory-facts" aria-label="Memory details">
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>State</dt><dd>{memory.state}</dd></div>
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
<div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
@@ -140,7 +145,7 @@
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span>
<span>{memory.source_candidates.length} shown</span>
</header>
{#if memory.source_candidates.length === 0}
@@ -226,7 +231,11 @@
</section>
<section class="detail-section revision-section" aria-labelledby="revision-heading">
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
<header class="section-heading">
<div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>
{#if data.revisions.data}<span>{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
<p class="section-intro">These are versions of this Memory record, separate from the Subject’s internal update number.</p>
{#if data.revisions.data}
{#if revisions.length === 0}
<div>
@@ -238,7 +247,7 @@
{#each revisions as revision (revision.revision)}
<li class:current={revision.revision === memory.revision}>
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div>
<div><strong>Memory revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{titleLabel(revision.state)}</span><span class="memory-kind">{titleLabel(revision.kind)}</span></div>
<p>{revision.claim}</p>
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
</a>
@@ -386,6 +395,7 @@
}
.memory-facts {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
}
@@ -414,6 +424,7 @@
.why-useful,
.staleness,
.change-reason,
.section-intro,
.empty-copy,
.bounded-note {
margin: 0;
@@ -75,6 +75,9 @@ test("renders the subject index as the Memory product entry", () => {
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
.toBeNull();
expect(screen.getByText("subject-1")).not.toBeNull();
expect(screen.getByText("Not connected")).not.toBeNull();
expect(screen.queryByText("Store revision", { exact: true })).toBeNull();
expect(screen.getByText("1 shown · more available")).not.toBeNull();
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
.toBe("/w/workspace-1/memory");
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
@@ -240,7 +243,10 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
workspaceId: "workspace-1",
subjectId: "subject-1",
cursor: "memory-current",
subject: result(subject()),
subject: result({
...subject(),
current_worker: { display_name: "Release Worker" },
}),
surface: result({
subject_id: "subject-1",
availability: "ready" as const,
@@ -298,9 +304,15 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
.textContent,
)
.toContain("Prefer explicit evidence.");
expect(screen.getByRole("article", { name: "Resident surface" })).not
expect(screen.getByRole("article", { name: "Resident context" })).not
.toBeNull();
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull();
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
expect(screen.getByText("Subject store revision", { exact: true })).not
.toBeNull();
expect(screen.getByText(/not a Memory count/)).not.toBeNull();
expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan(
0,
);
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
@@ -577,6 +589,93 @@ test("a refreshed loader snapshot fences old saves even after returning to the s
).toBeNull();
});
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
// stages a candidate without committing a Memory. Its public read projection is
// the same empty committed list as a new Subject; candidates are not a page input.
render(SubjectPage, {
data: {
workspaceId: "workspace-1",
subjectId: "subject-1",
cursor: null,
subject: result({ ...subject(), store_revision: 0 }),
surface: result({ subject_id: "subject-1", availability: "ungenerated" }),
memories: result({ limit: 100, items: [], has_more: false }),
},
} as never);
expect(screen.getByText("None", { exact: true })).not.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
expect(screen.queryByRole("article", { name: "Resident context" }))
.toBeNull();
expect(
screen.queryByText(
/candidate count|all Memories lost|automatically updating/i,
),
).toBeNull();
});
test.each(["ready", "ungenerated", "stale", "failed"] as const)(
"user behavior and diagnostic disclosures stay independent of %s generated Memory",
async (availability) => {
const record = { ...subject(), store_revision: 0 };
const view = render(SubjectPage, {
data: {
...subjectPageData(record),
surface: result({
subject_id: record.id,
availability,
...(availability === "ready"
? {
snapshot: {
snapshot_id: "empty-snapshot",
body_md: "",
memory_refs: [],
built_from_store_revision: 0,
created_at: "2026-01-02T03:04:05Z",
},
}
: {}),
}),
},
} as never);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(screen.getByText("Generated Memory context", { exact: true })).not
.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
const technical = view.container.querySelector(
"details.subject-technical-details",
) as HTMLDetailsElement;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain("Subject store revision");
if (availability === "ready") {
expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull();
expect(screen.getByText(/no generated Memory context to show/)).not
.toBeNull();
expect(
view.container.querySelector("details.surface-details")?.textContent,
)
.toContain("Generated from Subject revision");
}
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(technical.open).toBe(false);
},
);
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = {
workspaceId: "workspace-1",
@@ -600,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
}),
},
} as never);
expect(screen.getByText("Resident surface is ready and empty.")).not
expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull();
expect(screen.getByText(/current empty surface exists/)).not.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
await view.rerender(
{
data: {
...base,
surface: result({
subject_id: "subject-1",
availability: "ungenerated" as const,
}),
},
} as never,
);
expect(screen.getByText("Resident context has not been generated.")).not
.toBeNull();
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
await view.rerender(
{
@@ -614,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
},
} as never,
);
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
expect(screen.getByText("Resident context needs to be refreshed.")).not
.toBeNull();
await view.rerender(
{
@@ -630,7 +747,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
expect(screen.getByRole("alert").textContent).toContain("generation failed");
await view.rerender({ data: { ...base, surface: result(null) } } as never);
expect(screen.getByText("Resident surface data is unavailable.")).not
expect(screen.getByText("Resident context status is unavailable.")).not
.toBeNull();
await view.rerender(
@@ -639,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
} as never,
);
expect(screen.getByRole("alert").textContent).toContain(
"Resident surface unavailable",
"Resident context status unavailable",
);
});
@@ -1663,45 +1663,6 @@
</svelte:head>
<div class="console-shell worker-console-shell">
<section class="console-header card" aria-label="Worker controls">
<WorkerContextStatus metadata={selectedConsoleProjection.workerMetadata} />
<div class="console-header-actions">
<div
class="console-view-modes"
role="group"
aria-label="Console display mode"
>
<button
type="button"
class:active={consoleViewMode === "overview"}
aria-pressed={consoleViewMode === "overview"}
onclick={() => (consoleViewMode = "overview")}
>
Overview
</button>
<button
type="button"
class:active={consoleViewMode === "normal"}
aria-pressed={consoleViewMode === "normal"}
onclick={() => (consoleViewMode = "normal")}
>
Normal
</button>
</div>
<button
type="button"
class="secondary-button"
aria-expanded={workerDetailsOpen}
onclick={() => {
workerDetailsOpen = !workerDetailsOpen;
if (workerDetailsOpen) taskPaneOpen = false;
}}
>
Details
</button>
</div>
</section>
{#if rewindTargets.length > 0}
<section class="card rewind-targets" aria-label="Rewind targets">
<h3>Rewind targets</h3>
@@ -2106,6 +2067,28 @@
</div>
</div>
</form>
<WorkerContextStatus
metadata={selectedConsoleProjection.workerMetadata}
detailsOpen={workerDetailsOpen}
onToggleDetails={() => {
workerDetailsOpen = !workerDetailsOpen;
if (workerDetailsOpen) taskPaneOpen = false;
}}
>
{#snippet controls()}
<button
type="button"
class="console-view-toggle"
role="switch"
aria-checked={consoleViewMode === "overview"}
title="On: Overview · Off: Normal"
onclick={() => (consoleViewMode = consoleViewMode === "overview" ? "normal" : "overview")}
>
<span class="console-view-toggle-track" aria-hidden="true"></span>
<span>Overview</span>
</button>
{/snippet}
</WorkerContextStatus>
</div>
<style>
@@ -2131,14 +2114,6 @@
overscroll-behavior: contain;
}
.console-header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.console-card {
display: grid;
align-content: start;
@@ -2146,46 +2121,59 @@
min-height: 100%;
}
.console-header-actions {
display: flex;
.console-view-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
}
.console-view-modes {
display: inline-flex;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--bg-raised);
}
.console-view-modes button {
gap: var(--space-1);
min-height: var(--space-5);
padding: 0;
border: 0;
border-radius: var(--space-1);
background: transparent;
color: var(--text-muted);
padding: var(--space-1) var(--space-2);
font: inherit;
font-family: var(--font-sans);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 700;
font-weight: 400;
cursor: pointer;
}
.console-view-modes button + button {
border-left: 1px solid var(--line);
}
.console-view-modes button:hover {
.console-view-toggle:hover {
color: var(--text-strong);
}
.console-view-modes button.active {
background: var(--accent);
color: var(--bg);
.console-view-toggle:focus-visible {
outline: 1px solid var(--text-strong);
outline-offset: -1px;
}
.console-view-toggle-track {
position: relative;
flex: 0 0 auto;
width: 24px;
height: 14px;
border-radius: 999px;
background: var(--line-strong);
}
.console-view-toggle-track::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bg);
}
.console-view-toggle[aria-checked="true"] .console-view-toggle-track {
background: var(--accent-muted);
}
.console-view-toggle[aria-checked="true"] .console-view-toggle-track::before {
transform: translateX(10px);
}
.rewind-targets {
@@ -2684,14 +2672,5 @@
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.console-header {
align-items: stretch;
flex-direction: column;
}
.console-header-actions {
align-self: flex-end;
}
}
</style>
@@ -261,13 +261,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
await screen.findByText("Start of conversation");
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1);
await fireEvent.click(screen.getByRole("button", { name: "Normal" }));
const footer = screen.getByLabelText("Worker model and context");
const overview = screen.getByRole("switch", { name: "Overview" });
expect(screen.queryByRole("button", { name: "Normal" })).toBeNull();
const details = screen.getByRole("button", { name: "Details" });
expect(view.container.querySelector(".console-header")).toBeNull();
expect(footer.contains(overview)).toBe(true);
expect(overview.nextElementSibling).toBe(details);
expect(overview.closest("form")).toBeNull();
expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer);
expect(overview.getAttribute("aria-checked")).toBe("true");
await fireEvent.click(overview);
expect(overview.getAttribute("aria-checked")).toBe("false");
expect(overview.textContent?.trim()).toBe("Overview");
const transcript = screen.getByRole("article", { name: "main transcript" });
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(ids.length);
await fireEvent.click(screen.getByRole("button", { name: "Overview" }));
await fireEvent.click(overview);
expect(overview.getAttribute("aria-checked")).toBe("true");
expect(overview.textContent?.trim()).toBe("Overview");
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
});
test.each([
@@ -473,7 +488,7 @@ test("mini task summary opens and closes details without a header Tasks button",
latestListener().onFrame(subscribedFrame(session));
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks");
expect(view.container.querySelector(".console-header")).toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
const details = await screen.findByRole("complementary", { name: "Worker detail" });
expect(details.textContent).not.toContain("Capabilities");
+89 -2
View File
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
entrypoints: [],
}, fetcher);
assertEquals(calls.map((call) => call.url), [
assertEquals<unknown>(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/source-tree",
"/api/w/w%2Fone/config/source-tree/revisions/7",
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
"/api/w/w%2Fone/config/source-tree/commit",
]);
assertEquals(calls[3].init?.method, "POST");
assertEquals<unknown>(calls[3].init?.method, "POST");
assert(
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
);
});
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
return {
...tree,
contract: {
...tree.contract,
schema_bundle: {
...tree.contract.schema_bundle,
source,
contributions: [{
provider_id: "builtin",
namespace: "workspace",
version: "1",
source: contributionSource,
source_digest: "sha256:contribution",
}],
},
},
};
}
for (const field of ["bundle", "contribution"] as const) {
Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => {
const source = "schema body\n".repeat(6000);
const body = field === "bundle"
? withSchema(source)
: withSchema("builtin", source);
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
assertEquals<unknown>(
await commitConfigTree("w", {
base_revision: 7,
base_digest: snapshot.digest,
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
body,
);
});
Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => {
const body = (source: unknown) =>
field === "bundle" ? withSchema(source) : withSchema("builtin", source);
const limit = 8 * 1024 * 1024;
const atLimit = "é".repeat(limit / 2);
assertEquals<unknown>(
parseWorkspaceConfigTreeResponse(body(atLimit)),
body(atLimit),
);
for (const invalid of [atLimit + "x", null, 42, {}]) {
assertThrows(
() => parseWorkspaceConfigTreeResponse(body(invalid)),
ConfigSourceApiError,
);
}
});
}
Deno.test("config source API retains metadata and total response limits", async () => {
const invalid = withSchema("builtin");
invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat(
4097,
);
assertThrows(
() => parseWorkspaceConfigTreeResponse(invalid),
ConfigSourceApiError,
);
for (
const headers of [
new Headers(),
new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }),
]
) {
const fetcher = (() =>
Promise.resolve(
new Response(
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))),
{ headers },
),
)) as typeof fetch;
await assertRejects(
() => fetchConfigTree("w", fetcher),
ConfigSourceApiError,
"too large",
);
}
});
Deno.test("config source API rejects unknown response fields", async () => {
const fetcher =
(() =>
+50 -9
View File
@@ -62,9 +62,7 @@ async function assertSubjectCreateRejects(
assertEquals(error.status, expectedStatus);
if (!error.message.includes(expectedMessage)) {
throw new Error(
`expected ${JSON.stringify(error.message)} to include ${
JSON.stringify(expectedMessage)
}`,
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
);
}
return;
@@ -85,6 +83,30 @@ function subject(id = "subject-1") {
};
}
function currentWorker() {
return {
runtime_id: "runtime-1",
worker_id: "worker-1",
host_id: "host-1",
display_name: "Release Worker",
label: "Release Worker",
profile: "builtin:companion",
singleton_key: "subjektiv:subject-1",
tags: [],
workspace: {
visibility: "workspace",
identity: "workspace-1",
workspace_id: "workspace-1",
},
state: "idle",
last_seen_at: "2026-09-02T00:00:00Z",
pinned: false,
retention_state: "retained",
implementation: { kind: "embedded", display_hint: "Embedded" },
diagnostics: [],
};
}
function queryItem(id = "memory-1") {
return {
id,
@@ -190,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
() =>
parseSubjektivSubjectListResponse({
limit: 100,
items: Array.from({ length: 101 }, (_, index) =>
subject(`subject-${index}`)),
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
has_more: false,
}),
"bounded array",
);
});
Deno.test("Subject parser preserves live Worker connection state", () => {
assertEquals(
parseSubjektivSubjectResponse({
...subject(),
current_worker: currentWorker(),
}),
{ ...subject(), current_worker: currentWorker() },
);
assertEquals(
parseSubjektivSubjectResponse({ ...subject(), current_worker: null }),
{ ...subject(), current_worker: null },
);
assertThrows(
() =>
parseSubjektivSubjectResponse({
...subject(),
current_worker: { display_name: "Incomplete Worker" },
}),
"current worker.runtime_id",
);
});
Deno.test("Subject list parser preserves the exact bounded response", () => {
const response = { limit: 100, items: [subject()], has_more: false };
assertEquals(parseSubjektivSubjectListResponse(response), response);
@@ -401,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
throw new TypeError("connection reset");
}) as typeof fetch;
await assertSubjectCreateRejects(
() =>
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
"unknown_outcome",
"may have been created",
);
@@ -506,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
assertThrows(
() =>
parseSubjektivMemoryQueryResponse({
items: Array.from({ length: 101 }, (_, index) =>
queryItem(`memory-${index}`)),
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
has_more: false,
}),
"bounded array",