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:
Generated
+7
@@ -64,6 +64,7 @@ dependencies = [
|
|||||||
"dotenv",
|
"dotenv",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
|
"imagesize",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2188,6 +2189,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "include_dir"
|
name = "include_dir"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
|
|||||||
eventsource-stream = "0.2"
|
eventsource-stream = "0.2"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
imagesize = "0.14"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||||
agen-macros = { workspace = true }
|
agen-macros = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// engine history. An error rejects the item and aborts the turn, allowing
|
||||||
/// upper layers to make durable storage the commit gate.
|
/// upper layers to make durable storage the commit gate.
|
||||||
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
|
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 configuration (max_tokens, temperature, etc.)
|
||||||
request_config: RequestConfig,
|
request_config: RequestConfig,
|
||||||
/// Cancel notification channel (for interrupting execution)
|
/// 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));
|
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> {
|
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
|
||||||
for cb in &self.history_append_cbs {
|
for cb in &self.history_append_cbs {
|
||||||
cb(item).map_err(EngineError::HistoryAppend)?;
|
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"
|
"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();
|
self.tool_call_collector.begin_response();
|
||||||
let mut stream = ResponseStreamPump::start(stream);
|
let mut stream = ResponseStreamPump::start(stream);
|
||||||
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
|
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(),
|
warning_cbs: Vec::new(),
|
||||||
tool_result_cbs: Vec::new(),
|
tool_result_cbs: Vec::new(),
|
||||||
history_append_cbs: Vec::new(),
|
history_append_cbs: Vec::new(),
|
||||||
|
image_rejection_handler: None,
|
||||||
request_config: RequestConfig::default(),
|
request_config: RequestConfig::default(),
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
cancel_rx,
|
cancel_rx,
|
||||||
@@ -3856,6 +3898,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
|||||||
warning_cbs: self.warning_cbs,
|
warning_cbs: self.warning_cbs,
|
||||||
tool_result_cbs: self.tool_result_cbs,
|
tool_result_cbs: self.tool_result_cbs,
|
||||||
history_append_cbs: self.history_append_cbs,
|
history_append_cbs: self.history_append_cbs,
|
||||||
|
image_rejection_handler: self.image_rejection_handler,
|
||||||
request_config: self.request_config,
|
request_config: self.request_config,
|
||||||
|
|
||||||
cancel_tx: self.cancel_tx,
|
cancel_tx: self.cancel_tx,
|
||||||
@@ -4040,6 +4083,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
|
|||||||
warning_cbs: self.warning_cbs,
|
warning_cbs: self.warning_cbs,
|
||||||
tool_result_cbs: self.tool_result_cbs,
|
tool_result_cbs: self.tool_result_cbs,
|
||||||
history_append_cbs: self.history_append_cbs,
|
history_append_cbs: self.history_append_cbs,
|
||||||
|
image_rejection_handler: self.image_rejection_handler,
|
||||||
request_config: self.request_config,
|
request_config: self.request_config,
|
||||||
|
|
||||||
cancel_tx: self.cancel_tx,
|
cancel_tx: self.cancel_tx,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
mod engine;
|
mod engine;
|
||||||
mod handler;
|
mod handler;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod image_recovery;
|
||||||
mod message;
|
mod message;
|
||||||
|
|
||||||
pub(crate) mod callback;
|
pub(crate) mod callback;
|
||||||
|
|||||||
@@ -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> {
|
pub fn retry_after(&self) -> Option<Duration> {
|
||||||
match self {
|
match self {
|
||||||
ClientError::Api { retry_after, .. } => *retry_after,
|
ClientError::Api { retry_after, .. } => *retry_after,
|
||||||
|
|||||||
@@ -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.
|
/// 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
|
/// 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
|
/// immediate follow-up request and remain visible. With early dispatch, later
|
||||||
/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent
|
/// text/calls in the same response have not consumed its tool results: use the
|
||||||
/// requests keep the durable tool summary and can call the originating tool again when the image
|
/// confirmed response boundary, not that response's last individual item.
|
||||||
/// is still needed. Only the request-context clone is modified.
|
/// 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 {
|
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
|
||||||
let latest_model_output = items.iter().rposition(|item| {
|
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
|
||||||
item.is_assistant_message() || item.is_reasoning() || item.is_tool_call()
|
|
||||||
});
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for (index, item) in items.iter_mut().enumerate() {
|
for (index, item) in items.iter_mut().enumerate() {
|
||||||
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
|
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1071,6 +1071,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
|
|||||||
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
||||||
LogEntry::AnnotatedAssistantItem { .. }
|
LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use protocol::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -139,6 +139,17 @@ struct RetainedHistoryCursor {
|
|||||||
before_offset: u64,
|
before_offset: u64,
|
||||||
record_end_offset: Option<u64>,
|
record_end_offset: Option<u64>,
|
||||||
seed_entry_index: Option<usize>,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -445,6 +456,7 @@ pub fn read_retained_session_history_page(
|
|||||||
limits,
|
limits,
|
||||||
&mut scanned_bytes,
|
&mut scanned_bytes,
|
||||||
&mut scanned_entries,
|
&mut scanned_entries,
|
||||||
|
&mut Vec::new(),
|
||||||
)?;
|
)?;
|
||||||
Some(SessionHistoryLineageBoundary {
|
Some(SessionHistoryLineageBoundary {
|
||||||
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
|
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
|
||||||
@@ -456,6 +468,10 @@ pub fn read_retained_session_history_page(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let mut correction_positions = cursor
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.corrections.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let mut turns = read_history_turns_backward(
|
let mut turns = read_history_turns_backward(
|
||||||
&store,
|
&store,
|
||||||
active.session_id,
|
active.session_id,
|
||||||
@@ -465,11 +481,23 @@ pub fn read_retained_session_history_page(
|
|||||||
limits,
|
limits,
|
||||||
&mut scanned_bytes,
|
&mut scanned_bytes,
|
||||||
&mut scanned_entries,
|
&mut scanned_entries,
|
||||||
|
&mut correction_positions,
|
||||||
)?;
|
)?;
|
||||||
let has_more = turns.len() > page_limit;
|
let has_more = turns.len() > page_limit;
|
||||||
if has_more {
|
if has_more {
|
||||||
turns.truncate(page_limit);
|
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 next_cursor = if has_more {
|
||||||
let oldest = turns
|
let oldest = turns
|
||||||
.last()
|
.last()
|
||||||
@@ -485,6 +513,7 @@ pub fn read_retained_session_history_page(
|
|||||||
before_offset: oldest.position.before_offset,
|
before_offset: oldest.position.before_offset,
|
||||||
record_end_offset: oldest.position.record_end_offset,
|
record_end_offset: oldest.position.record_end_offset,
|
||||||
seed_entry_index: oldest.position.seed_entry_index,
|
seed_entry_index: oldest.position.seed_entry_index,
|
||||||
|
corrections: correction_positions,
|
||||||
})?)
|
})?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -702,6 +731,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
|
|||||||
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
||||||
LogEntry::AnnotatedAssistantItem { .. }
|
LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
@@ -814,10 +844,56 @@ fn read_history_turns_backward(
|
|||||||
limits: RetainedHistoryReadLimits,
|
limits: RetainedHistoryReadLimits,
|
||||||
scanned_bytes: &mut u64,
|
scanned_bytes: &mut u64,
|
||||||
scanned_entries: &mut usize,
|
scanned_entries: &mut usize,
|
||||||
|
correction_positions: &mut Vec<ToolCorrectionPosition>,
|
||||||
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
|
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
|
||||||
let mut turns = Vec::new();
|
let mut turns = Vec::new();
|
||||||
let mut pending_entries = Vec::new();
|
let mut pending_entries = Vec::new();
|
||||||
let mut seen_entries = HashSet::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 {
|
let (start_segment_index, start_position, cursor_turn_id) = match start {
|
||||||
Some((position, turn_id)) => {
|
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) {
|
for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) {
|
||||||
let starting_here = segment_index == 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(
|
let adopted_end = locate_adopted_segment_end(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -923,6 +973,32 @@ fn read_history_turns_backward(
|
|||||||
if before_offset > adopted_end {
|
if before_offset > adopted_end {
|
||||||
return Err(RetainedHistoryReadError::InvalidCursor);
|
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
|
let mut reader = store
|
||||||
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
|
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
|
||||||
.map_err(map_history_store_error)?;
|
.map_err(map_history_store_error)?;
|
||||||
@@ -949,7 +1025,25 @@ fn read_history_turns_backward(
|
|||||||
}
|
}
|
||||||
continue;
|
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(
|
if collect_history_entries_backward(
|
||||||
&entries,
|
&entries,
|
||||||
segment.segment_id,
|
segment.segment_id,
|
||||||
@@ -967,7 +1061,7 @@ fn read_history_turns_backward(
|
|||||||
|
|
||||||
if segment_index + 1 == lineage.len()
|
if segment_index + 1 == lineage.len()
|
||||||
&& collect_history_entries_backward(
|
&& collect_history_entries_backward(
|
||||||
&segment.seed_entries,
|
&corrected_history_entries(&segment.seed_entries, &corrections),
|
||||||
segment.segment_id,
|
segment.segment_id,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
@@ -984,6 +1078,38 @@ fn read_history_turns_backward(
|
|||||||
Ok(turns)
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn collect_history_entries_backward(
|
fn collect_history_entries_backward(
|
||||||
entries: &[SessionSnapshotEntry],
|
entries: &[SessionSnapshotEntry],
|
||||||
@@ -1052,9 +1178,13 @@ fn is_user_entry(entry: &SessionSnapshotEntry) -> bool {
|
|||||||
fn encode_history_cursor(
|
fn encode_history_cursor(
|
||||||
cursor: &RetainedHistoryCursor,
|
cursor: &RetainedHistoryCursor,
|
||||||
) -> Result<String, RetainedHistoryReadError> {
|
) -> Result<String, RetainedHistoryReadError> {
|
||||||
serde_json::to_vec(cursor)
|
let encoded = serde_json::to_vec(cursor)
|
||||||
.map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
|
.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> {
|
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));
|
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(
|
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
|
||||||
&entry.item,
|
&entry.item,
|
||||||
entry.metadata.entry_id.0.clone(),
|
entry.metadata.entry_id.0.clone(),
|
||||||
@@ -1782,6 +1925,129 @@ mod tests {
|
|||||||
.collect()
|
.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]
|
#[test]
|
||||||
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
|
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
|
||||||
let worker_name = "worker-history-12";
|
let worker_name = "worker-history-12";
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ pub enum LogEntry {
|
|||||||
|
|
||||||
/// Canonical tool output and metadata committed as one journal record.
|
/// Canonical tool output and metadata committed as one journal record.
|
||||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
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
|
/// Canonical typed system event and model-visible metadata committed
|
||||||
/// together.
|
/// together.
|
||||||
@@ -399,6 +402,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.annotated_history.push(entry.clone());
|
state.annotated_history.push(entry.clone());
|
||||||
state.history.push(Item::from(entry.item.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 {
|
LogEntry::AnnotatedSystemItem {
|
||||||
entry, extensions, ..
|
entry, extensions, ..
|
||||||
} => {
|
} => {
|
||||||
|
|||||||
@@ -381,10 +381,10 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_with_permission(
|
fn execute_with_optional_timeout(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
permission: &'static str,
|
timeout: Option<Duration>,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
let method = match request.method {
|
let method = match request.method {
|
||||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||||
@@ -409,9 +409,9 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
path_and_query: request.path,
|
path_and_query: request.path,
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
permission: permission.to_string(),
|
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||||
worker_id: Some(self.worker_id.clone()),
|
worker_id: Some(self.worker_id.clone()),
|
||||||
timeout: self.request_timeout,
|
timeout,
|
||||||
max_response_bytes: 8 * 1024 * 1024,
|
max_response_bytes: 8 * 1024 * 1024,
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -478,7 +478,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
&self,
|
&self,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> 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(
|
fn execute_server_operation(
|
||||||
@@ -1341,6 +1349,25 @@ mod tests {
|
|||||||
server.join().unwrap();
|
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]
|
#[test]
|
||||||
fn runtime_owned_workspace_client_reports_request_timeouts() {
|
fn runtime_owned_workspace_client_reports_request_timeouts() {
|
||||||
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
|
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
use arc_swap::ArcSwap;
|
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(
|
async fn execute_subjektiv_memory_operation(
|
||||||
client: &dyn WorkspaceClient,
|
client: &dyn WorkspaceClient,
|
||||||
operation: server_api::SubjektivMemoryBackendOperation,
|
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> {
|
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
|
||||||
let workspace_id =
|
let workspace_id =
|
||||||
client
|
client
|
||||||
@@ -170,11 +186,14 @@ async fn execute_subjektiv_memory_operation(
|
|||||||
client.kind()
|
client.kind()
|
||||||
),
|
),
|
||||||
})?;
|
})?;
|
||||||
let response = client.execute(WorkspaceRequest::json(
|
let response = execute(
|
||||||
WorkspaceRequestMethod::Post,
|
client,
|
||||||
format!("/api/w/{workspace_id}/subjektiv/memory"),
|
WorkspaceRequest::json(
|
||||||
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
|
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)
|
let status = reqwest::StatusCode::from_u16(response.status)
|
||||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
if !response.is_success() {
|
if !response.is_success() {
|
||||||
@@ -712,12 +731,13 @@ impl WorkspaceSubjektivResidentSummarySource {
|
|||||||
async fn fetch(
|
async fn fetch(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
||||||
match execute_subjektiv_memory_operation(
|
match execute_subjektiv_memory_operation_with(
|
||||||
self.client.as_ref(),
|
self.client.as_ref(),
|
||||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
||||||
)
|
|client, request| {
|
||||||
.await?
|
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
||||||
{
|
},
|
||||||
|
)? {
|
||||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
||||||
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
||||||
"unexpected subject resident context response: {other:?}"
|
"unexpected subject resident context response: {other:?}"
|
||||||
@@ -1291,6 +1311,7 @@ mod tests {
|
|||||||
scope_allowed: bool,
|
scope_allowed: bool,
|
||||||
behavior: Mutex<(u64, String)>,
|
behavior: Mutex<(u64, String)>,
|
||||||
paths: Mutex<Vec<String>>,
|
paths: Mutex<Vec<String>>,
|
||||||
|
timeouts: Mutex<Vec<Duration>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubjectResidentClient {
|
impl SubjectResidentClient {
|
||||||
@@ -1304,6 +1325,7 @@ mod tests {
|
|||||||
scope_allowed: true,
|
scope_allowed: true,
|
||||||
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||||
paths: Mutex::new(Vec::new()),
|
paths: Mutex::new(Vec::new()),
|
||||||
|
timeouts: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,6 +1336,7 @@ mod tests {
|
|||||||
scope_allowed: false,
|
scope_allowed: false,
|
||||||
behavior: Mutex::new((3, "must not be injected".to_string())),
|
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||||
paths: Mutex::new(Vec::new()),
|
paths: Mutex::new(Vec::new()),
|
||||||
|
timeouts: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1367,6 +1390,15 @@ mod tests {
|
|||||||
.unwrap(),
|
.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> {
|
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
|
||||||
@@ -1681,6 +1713,10 @@ permission = "write"
|
|||||||
ready_client.paths.lock().unwrap().clone(),
|
ready_client.paths.lock().unwrap().clone(),
|
||||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
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(
|
let ready_empty_client = Arc::new(SubjectResidentClient::new(
|
||||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
@@ -1790,6 +1826,12 @@ permission = "write"
|
|||||||
.body
|
.body
|
||||||
.contains("No user-managed behavior is set")
|
.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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -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 { .. }
|
entry @ (LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::RunYielded { .. }
|
| LogEntry::RunYielded { .. }
|
||||||
| LogEntry::RunResumed { .. }
|
| LogEntry::RunResumed { .. }
|
||||||
| LogEntry::RunCancelled { .. }
|
| LogEntry::RunCancelled { .. }
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl SegmentLogSink {
|
|||||||
| LogEntry::AnnotatedUserInput { .. }
|
| LogEntry::AnnotatedUserInput { .. }
|
||||||
| LogEntry::AnnotatedAssistantItem { .. }
|
| LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. }
|
| LogEntry::AnnotatedSystemItem { .. }
|
||||||
| LogEntry::Invoke { .. }
|
| LogEntry::Invoke { .. }
|
||||||
| LogEntry::RunYielded { .. }
|
| LogEntry::RunYielded { .. }
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries(
|
|||||||
}
|
}
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(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(
|
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||||
entry.item.to_history_item(),
|
entry.item.to_history_item(),
|
||||||
entry.metadata.clone(),
|
entry.metadata.clone(),
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
#[path = "image_recovery_tests.rs"]
|
||||||
|
mod image_recovery_tests;
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -734,6 +738,17 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
|||||||
fn execute(&self, request: WorkspaceRequest)
|
fn execute(&self, request: WorkspaceRequest)
|
||||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
-> 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(
|
fn execute_server_operation(
|
||||||
&self,
|
&self,
|
||||||
operation: WorkspaceServerOperation,
|
operation: WorkspaceServerOperation,
|
||||||
@@ -4061,6 +4076,24 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
St: Clone + 'static,
|
St: Clone + 'static,
|
||||||
{
|
{
|
||||||
self.materialize_durable_session_head().await?;
|
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();
|
self.ensure_interceptor_installed();
|
||||||
if self.should_pre_run_compact() {
|
if self.should_pre_run_compact() {
|
||||||
self.try_pre_run_compact().await?;
|
self.try_pre_run_compact().await?;
|
||||||
|
|||||||
@@ -162,11 +162,11 @@ use crate::hosts::{
|
|||||||
RuntimeRegistryError, RuntimeRegistryUnregisterResult, TicketWorkerRole,
|
RuntimeRegistryError, RuntimeRegistryUnregisterResult, TicketWorkerRole,
|
||||||
WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
|
WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
|
||||||
WorkerInputDisposition, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
|
WorkerInputDisposition, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
|
||||||
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceRequirement,
|
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceEvidence,
|
||||||
WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest,
|
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
|
||||||
WorkerTicketAssignmentRequest, WorkspaceRuntimeAuthorization,
|
WorkerSpawnWorkingDirectoryRequest, WorkerTicketAssignmentRequest,
|
||||||
is_disallowed_remote_runtime_address, is_loopback_runtime_origin,
|
WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address,
|
||||||
worker_spawn_create_fingerprint, workspace_worker_summary,
|
is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary,
|
||||||
};
|
};
|
||||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||||
use crate::memory_staging::{
|
use crate::memory_staging::{
|
||||||
@@ -27712,6 +27712,11 @@ async fn create_workspace_worker_inner(
|
|||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(error) => return Err(error.into()),
|
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(
|
Ok(Json(record_browser_worker_spawn(
|
||||||
&api,
|
&api,
|
||||||
runtime_id,
|
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(
|
fn record_browser_worker_spawn(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
requested_runtime_id: String,
|
requested_runtime_id: String,
|
||||||
@@ -39609,7 +39660,7 @@ mod tests {
|
|||||||
async fn ordinary_worker_launch_validates_optional_subjektiv_connection() {
|
async fn ordinary_worker_launch_validates_optional_subjektiv_connection() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(workspace.path());
|
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 store = open_subjektiv_store(&api).unwrap();
|
||||||
let active = store
|
let active = store
|
||||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||||
@@ -39704,6 +39755,31 @@ mod tests {
|
|||||||
first.worker.singleton_key.as_deref(),
|
first.worker.singleton_key.as_deref(),
|
||||||
Some(format!("subjektiv:{}", active.id).as_str())
|
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]
|
#[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.reload();
|
||||||
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||||
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
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.
|
// A wide touchscreen still has a temporary-open control in hover mode.
|
||||||
await touchPage.setViewportSize({ width: 1024, height: 600 });
|
await touchPage.setViewportSize({ width: 1024, height: 600 });
|
||||||
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
|
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}`,
|
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
|
||||||
);
|
);
|
||||||
await page.locator(".task-mini-row").waitFor();
|
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 queue = mode === "both" || mode === "queue";
|
||||||
const notifications = ["both", "notifications", "legacy"].includes(mode);
|
const notifications = ["both", "notifications", "legacy"].includes(mode);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const subjects = [
|
|||||||
42,
|
42,
|
||||||
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
|
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
|
||||||
3,
|
3,
|
||||||
|
connectedWorker("Release coordination Worker with a deliberately long display name"),
|
||||||
),
|
),
|
||||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||||
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
||||||
@@ -82,6 +83,30 @@ const subjects = [
|
|||||||
];
|
];
|
||||||
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
|
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(
|
function subject(
|
||||||
id: string,
|
id: string,
|
||||||
role: string,
|
role: string,
|
||||||
@@ -89,6 +114,7 @@ function subject(
|
|||||||
storeRevision: number,
|
storeRevision: number,
|
||||||
behaviorMd = "",
|
behaviorMd = "",
|
||||||
behaviorRevision = 0,
|
behaviorRevision = 0,
|
||||||
|
currentWorker?: ReturnType<typeof connectedWorker>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -99,6 +125,7 @@ function subject(
|
|||||||
store_revision: storeRevision,
|
store_revision: storeRevision,
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-02T03:04:05Z",
|
updated_at: "2026-01-02T03:04:05Z",
|
||||||
|
...(currentWorker ? { current_worker: currentWorker } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +168,7 @@ function memoriesFor(subjectId: string) {
|
|||||||
1,
|
1,
|
||||||
"working_assumption",
|
"working_assumption",
|
||||||
"retracted",
|
"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",
|
evidence_id: "evidence-message-0001",
|
||||||
origin: { kind: "human_input", account_id: "account-fixture" },
|
origin: { kind: "human_input", account_id: "account-fixture" },
|
||||||
evidence_kind: "message",
|
evidence_kind: "message",
|
||||||
label: "T-672 product direction",
|
label: "Memory information design source",
|
||||||
summary: "Bounded source reference retained with the committed revision.",
|
summary: "Bounded source reference retained with the committed revision.",
|
||||||
}],
|
}],
|
||||||
source_refs_total: 1,
|
source_refs_total: 1,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const memoryId =
|
|||||||
const subjectPageCursor = "fixture-subject-page-2";
|
const subjectPageCursor = "fixture-subject-page-2";
|
||||||
const createInteractionArtifacts = join(
|
const createInteractionArtifacts = join(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
"target/web-ux/t-693-subject-behavior",
|
"target/web-ux/t-692-browser-interactions",
|
||||||
);
|
);
|
||||||
|
|
||||||
async function freePort(): Promise<number> {
|
async function freePort(): Promise<number> {
|
||||||
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
|
|||||||
const document = (globalThis as any).document;
|
const document = (globalThis as any).document;
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
const marker = [...document.querySelectorAll("h3")].find((heading) =>
|
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;
|
if (!main || !marker) return false;
|
||||||
const mainRect = main.getBoundingClientRect();
|
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="https://example.com"]').count(), 1);
|
||||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
|
await page.getByRole("article", { name: "Resident context" }).locator("img, script")
|
||||||
.count(),
|
.count(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -290,7 +290,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
await page.locator("main").hover();
|
await page.locator("main").hover();
|
||||||
await page.mouse.wheel(0, 100_000);
|
await page.mouse.wheel(0, 100_000);
|
||||||
} else if (scenario.label === "keyboard") {
|
} 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");
|
await page.keyboard.press("End");
|
||||||
} else {
|
} else {
|
||||||
await swipeMainToEnd(page);
|
await swipeMainToEnd(page);
|
||||||
@@ -321,17 +321,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
level: 1,
|
level: 1,
|
||||||
}).waitFor();
|
}).waitFor();
|
||||||
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
|
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.getByText("memory-derived-source-0002").count(), 1);
|
||||||
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).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("Memory revision 3", { exact: true }).count(), 1);
|
||||||
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
|
assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
|
||||||
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0);
|
assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
|
||||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||||
"link",
|
"link",
|
||||||
{ name: "Next page →" },
|
{ name: "Next page →" },
|
||||||
).click();
|
).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"));
|
assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
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();
|
const paginationPage = await paginationContext.newPage();
|
||||||
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||||
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor();
|
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);
|
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
|
||||||
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
|
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
|
||||||
"link",
|
"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(),
|
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
const currentWorker = createPage.locator(".subject-facts div").filter({
|
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
|
||||||
hasText: "Current worker",
|
"..",
|
||||||
});
|
);
|
||||||
assert((await currentWorker.textContent())?.includes("None"));
|
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();
|
await createPage.getByRole("button", { name: "Edit" }).click();
|
||||||
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
|
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
|
||||||
await behaviorEditor.fill(
|
await behaviorEditor.fill(
|
||||||
@@ -519,21 +530,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
const stateCases = [
|
const stateCases = [
|
||||||
{
|
{
|
||||||
id: "empty-subject",
|
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,
|
role: "status" as const,
|
||||||
},
|
},
|
||||||
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
|
|
||||||
{
|
{
|
||||||
id: "failed-subject",
|
id: "failed-subject",
|
||||||
text: "Resident surface generation failed.",
|
text: "Resident context generation failed.",
|
||||||
role: "alert" as const,
|
role: "alert" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ungenerated-subject",
|
id: "ungenerated-subject",
|
||||||
text: "Resident surface has not been generated.",
|
text: "Resident context has not been generated.",
|
||||||
role: "status" as const,
|
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) {
|
for (const stateCase of stateCases) {
|
||||||
const page = await stateContext.newPage();
|
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 });
|
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
|
||||||
await state.waitFor();
|
await state.waitFor();
|
||||||
assert((await state.textContent())?.includes(stateCase.text));
|
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") {
|
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 assertNoPageWideOverflow(page);
|
||||||
await page.close();
|
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.",
|
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||||
"ready": {
|
"ready": {
|
||||||
"kind": "selector",
|
"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",
|
"id": "detail",
|
||||||
@@ -150,6 +174,18 @@
|
|||||||
},
|
},
|
||||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
"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",
|
"id": "stale",
|
||||||
"label": "Stale subject surface",
|
"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.",
|
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||||
"ready": {
|
"ready": {
|
||||||
"kind": "selector",
|
"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",
|
"id": "detail",
|
||||||
@@ -150,6 +174,18 @@
|
|||||||
},
|
},
|
||||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
"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",
|
"id": "stale",
|
||||||
"label": "Stale subject surface",
|
"label": "Stale subject surface",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
parseWorkerLaunchOptionsResponse,
|
parseWorkerLaunchOptionsResponse,
|
||||||
parseWorkerSummary,
|
parseWorkerSummary,
|
||||||
} from "./workers.ts";
|
} from "./workers.ts";
|
||||||
|
import { loadJson } from "./http.ts";
|
||||||
|
|
||||||
const worker = {
|
const worker = {
|
||||||
runtime_id: "runtime-a",
|
runtime_id: "runtime-a",
|
||||||
@@ -111,6 +112,92 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
|
|||||||
assertEquals(observedStopped.state, "stopped");
|
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", () => {
|
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
alias: "sessions",
|
alias: "sessions",
|
||||||
|
|||||||
@@ -262,7 +262,16 @@ function workerStateSnapshot(
|
|||||||
label: string,
|
label: string,
|
||||||
): WorkerStateSnapshot {
|
): WorkerStateSnapshot {
|
||||||
const snapshot = record(value, label);
|
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 state = record(snapshot.state, `${label}.state`);
|
||||||
const kind = string(state.kind, `${label}.state.kind`);
|
const kind = string(state.kind, `${label}.state.kind`);
|
||||||
if (kind === "idle") {
|
if (kind === "idle") {
|
||||||
@@ -272,6 +281,7 @@ function workerStateSnapshot(
|
|||||||
snapshot.last_command_id,
|
snapshot.last_command_id,
|
||||||
`${label}.last_command_id`,
|
`${label}.last_command_id`,
|
||||||
),
|
),
|
||||||
|
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
|
||||||
state: { kind: "idle" },
|
state: { kind: "idle" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -291,6 +301,7 @@ function workerStateSnapshot(
|
|||||||
snapshot.last_command_id,
|
snapshot.last_command_id,
|
||||||
`${label}.last_command_id`,
|
`${label}.last_command_id`,
|
||||||
),
|
),
|
||||||
|
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
|
||||||
state: {
|
state: {
|
||||||
kind: "busy",
|
kind: "busy",
|
||||||
state: busyKind === "run"
|
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 newPath = $state("module.dcdl");
|
||||||
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||||
let status = $state("Loading source tree…");
|
let status = $state("Loading source tree…");
|
||||||
|
let loadState = $state<"loading" | "error" | "ready">("loading");
|
||||||
|
let mounted = false;
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||||
let baseRevision = $state(0);
|
let baseRevision = $state(0);
|
||||||
@@ -42,9 +44,12 @@
|
|||||||
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
toolchain = new ConfigSourceToolchain();
|
mounted = true;
|
||||||
void reload();
|
void reload();
|
||||||
return () => toolchain?.close();
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
toolchain?.close();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -70,9 +75,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
loadState = "loading";
|
||||||
|
status = "Loading source tree…";
|
||||||
analysisReady = false;
|
analysisReady = false;
|
||||||
|
analysisGeneration += 1;
|
||||||
try {
|
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]) {
|
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||||
}
|
}
|
||||||
@@ -80,15 +96,19 @@
|
|||||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||||
baseRevision = treeState.snapshot.revision;
|
baseRevision = treeState.snapshot.revision;
|
||||||
baseDigest = treeState.snapshot.digest;
|
baseDigest = treeState.snapshot.digest;
|
||||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
|
||||||
analysisReady = true;
|
analysisReady = true;
|
||||||
workingChanges = [];
|
workingChanges = [];
|
||||||
renamePath = selectedPath;
|
renamePath = selectedPath;
|
||||||
diagnostics = [];
|
diagnostics = [];
|
||||||
conflict = false;
|
conflict = false;
|
||||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||||
|
loadState = "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
toolchain?.close();
|
||||||
|
toolchain = null;
|
||||||
status = String(error);
|
status = String(error);
|
||||||
|
loadState = "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +233,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discardAndReload() {
|
async function discardAndReload() {
|
||||||
workingChanges = [];
|
|
||||||
source = "";
|
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +305,18 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<aside class="config-source-tree">
|
||||||
<div class="config-source-tree__header">
|
<div class="config-source-tree__header">
|
||||||
<strong>Source tree</strong>
|
<strong>Source tree</strong>
|
||||||
@@ -354,4 +383,12 @@
|
|||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.config-source-load {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
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_ERROR_BYTES = 4 * 1024;
|
||||||
const MAX_ENTRY_COUNT = 256;
|
const MAX_ENTRY_COUNT = 256;
|
||||||
const MAX_ENTRY_BYTES = 256 * 1024;
|
const MAX_ENTRY_BYTES = 256 * 1024;
|
||||||
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
|||||||
provider_id: boundedString(item.provider_id),
|
provider_id: boundedString(item.provider_id),
|
||||||
namespace: boundedString(item.namespace),
|
namespace: boundedString(item.namespace),
|
||||||
version: boundedString(item.version),
|
version: boundedString(item.version),
|
||||||
source: boundedString(item.source),
|
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||||
...(projection === undefined ? {} : { projection_validator: projection }),
|
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||||
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
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"]);
|
const item = record(value, ["contributions", "source", "fingerprint"]);
|
||||||
return {
|
return {
|
||||||
contributions: boundedArray(item.contributions).map(parseContribution),
|
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),
|
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte";
|
|||||||
|
|
||||||
afterEach(cleanup);
|
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", () => {
|
test("renders resolved model, reasoning, and measured context accessibly", () => {
|
||||||
const view = render(WorkerContextStatus, {
|
const view = render(WorkerContextStatus, {
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () =>
|
|||||||
|
|
||||||
const status = view.getByLabelText("Worker model and context");
|
const status = view.getByLabelText("Worker model and context");
|
||||||
expect(status.textContent).toContain("gpt-6-astra · high");
|
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 () => {
|
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
|
||||||
const view = render(WorkerContextStatus, { metadata: null });
|
const view = render(WorkerContextStatus, { metadata: null });
|
||||||
expect(view.getByText("Model unavailable")).not.toBeNull();
|
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%");
|
expect(view.container.textContent).not.toContain("0%");
|
||||||
|
|
||||||
await view.rerender({
|
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("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({
|
await view.rerender({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
import type { ConsoleWorkerMetadata } from "./model";
|
import type { ConsoleWorkerMetadata } from "./model";
|
||||||
import {
|
import {
|
||||||
formatContextSummary,
|
formatContextSummary,
|
||||||
@@ -7,23 +8,51 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
metadata: ConsoleWorkerMetadata | null;
|
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 modelSummary = $derived(formatModelSummary(metadata));
|
||||||
const contextSummary = $derived(formatContextSummary(metadata));
|
const contextSummary = $derived(formatContextSummary(metadata));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="worker-context-status" aria-label="Worker model and context">
|
<div class="worker-context-status" aria-label="Worker model and context">
|
||||||
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.worker-context-status {
|
.worker-context-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-inline: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -50,20 +79,44 @@
|
|||||||
color: var(--line-strong);
|
color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.worker-context-actions {
|
||||||
.worker-context-status {
|
display: flex;
|
||||||
width: 100%;
|
flex: 0 0 auto;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
row-gap: 0;
|
gap: var(--space-2);
|
||||||
}
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.separator {
|
.details-button {
|
||||||
display: none;
|
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,
|
.details-button:hover,
|
||||||
.worker-context {
|
.details-button[aria-expanded="true"] {
|
||||||
flex: 1 1 100%;
|
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>
|
</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[] {
|
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
||||||
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
|
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
|
||||||
kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1,
|
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,
|
contextTokens: 142_000,
|
||||||
contextSource: "estimated",
|
contextSource: "estimated",
|
||||||
}),
|
}),
|
||||||
"Context ~142.0k / 272.0k (52%)",
|
"~142.0k / 272.0k (52%)",
|
||||||
);
|
);
|
||||||
assertEquals(formatModelSummary(null), "Model unavailable");
|
assertEquals(formatModelSummary(null), "Model unavailable");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
|||||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
||||||
"reasoning unavailable",
|
"reasoning unavailable",
|
||||||
);
|
);
|
||||||
assertEquals(formatContextSummary(null), "Context unavailable");
|
assertEquals(formatContextSummary(null), "unavailable");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
formatContextSummary({
|
formatContextSummary({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
|||||||
contextTokens: 0,
|
contextTokens: 0,
|
||||||
contextSource: null,
|
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('"/subjektiv/subjects"') &&
|
||||||
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
||||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
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"'),
|
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||||
"Memory product entry should list explicit typed 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}/surface`") &&
|
||||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||||
subjectPage.includes("DocumentMarkdown from") &&
|
subjectPage.includes("DocumentMarkdown from") &&
|
||||||
subjectPage.includes("Resident surface") &&
|
subjectPage.includes("Resident context") &&
|
||||||
|
subjectPage.includes("Subject store revision") &&
|
||||||
subjectPage.includes("Current Memories") &&
|
subjectPage.includes("Current Memories") &&
|
||||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||||
|
subjectPage.includes("surface?.availability === 'ungenerated'") &&
|
||||||
subjectPage.includes("surface?.availability === 'stale'") &&
|
subjectPage.includes("surface?.availability === 'stale'") &&
|
||||||
|
subjectPage.includes("surface?.availability === 'failed'") &&
|
||||||
subjectPage.includes("data-surface-ready-empty") &&
|
subjectPage.includes("data-surface-ready-empty") &&
|
||||||
!subjectPage.includes("overflow-y"),
|
!subjectPage.includes("overflow-y"),
|
||||||
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
"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 () => {
|
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
|
||||||
const files = [
|
const files = [
|
||||||
"ComposerInput.svelte",
|
"ComposerInput.svelte",
|
||||||
@@ -1551,14 +1582,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
|
|||||||
const input = await Deno.readTextFile(
|
const input = await Deno.readTextFile(
|
||||||
new URL("./ComposerInput.svelte", import.meta.url),
|
new URL("./ComposerInput.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const header = page.slice(
|
|
||||||
page.indexOf('<section class="console-header'),
|
|
||||||
page.indexOf("{#if rewindTargets.length"),
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
!header.includes("Compact") && !header.includes("Rewind") &&
|
!page.includes('class="console-header') &&
|
||||||
!page.includes("requestRewindTargets"),
|
!page.includes("requestRewindTargets"),
|
||||||
"Header must defer Compact/Rewind to commands",
|
"Compact/Rewind must remain commands without a dedicated header",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
input.includes('role="listbox"') && input.includes('role="option"') &&
|
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(
|
const component = await Deno.readTextFile(
|
||||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const header = page.slice(
|
|
||||||
page.indexOf('<section class="console-header'),
|
|
||||||
page.indexOf("{#if rewindTargets.length"),
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
!header.includes("taskPaneOpen = !taskPaneOpen") &&
|
!page.includes('class="console-header') &&
|
||||||
page.includes("onTogglePane={() => {"),
|
page.includes("onTogglePane={() => {"),
|
||||||
"Only the mini summary should own Tasks toggling",
|
"Only the mini summary should own Tasks toggling",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export function formatContextSummary(
|
|||||||
used === null || used === undefined || limit === null ||
|
used === null || used === undefined || limit === null ||
|
||||||
limit === undefined || !Number.isSafeInteger(used) ||
|
limit === undefined || !Number.isSafeInteger(used) ||
|
||||||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
|
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
|
||||||
) return "Context unavailable";
|
) return "unavailable";
|
||||||
const percent = Math.round((used / limit) * 100);
|
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" ? "~" : "";
|
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
|
||||||
return `Context ${estimate}${formatRunTokens(used)} / ${
|
return `${estimate}${formatRunTokens(used)} / ${
|
||||||
formatRunTokens(limit)
|
formatRunTokens(limit)
|
||||||
} (${percent}%)`;
|
} (${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 CompletionContext,
|
||||||
type CompletionResult,
|
type CompletionResult,
|
||||||
} from '@codemirror/autocomplete';
|
} from '@codemirror/autocomplete';
|
||||||
|
import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
|
||||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
||||||
@@ -129,6 +130,8 @@
|
|||||||
decodal({ highlight: false }),
|
decodal({ highlight: false }),
|
||||||
syntaxHighlighting(syntaxTheme),
|
syntaxHighlighting(syntaxTheme),
|
||||||
autocompletion({
|
autocompletion({
|
||||||
|
// Use the explicit keymap below so Enter never accepts a suggestion.
|
||||||
|
defaultKeymap: false,
|
||||||
activateOnTyping: false,
|
activateOnTyping: false,
|
||||||
override: [
|
override: [
|
||||||
async (context: CompletionContext) => {
|
async (context: CompletionContext) => {
|
||||||
@@ -139,6 +142,10 @@
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
keymap.of(completionKeymapWithoutEnter),
|
keymap.of(completionKeymapWithoutEnter),
|
||||||
|
keymap.of([
|
||||||
|
{ key: 'Enter', run: insertNewlineAndIndent },
|
||||||
|
indentWithTab,
|
||||||
|
]),
|
||||||
fixedSchemaWrapperCompartment.of(
|
fixedSchemaWrapperCompartment.of(
|
||||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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 Bevel from '#lib/workspace/ui/Bevel.svelte';
|
||||||
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
@@ -77,6 +78,19 @@
|
|||||||
focusInside = false;
|
focusInside = false;
|
||||||
clearCloseTimer();
|
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);
|
onDestroy(clearCloseTimer);
|
||||||
</script>
|
</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;
|
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
|
||||||
|
return state === 'active' ? 'Available' : 'Retired';
|
||||||
|
}
|
||||||
|
|
||||||
async function openCreateForm(): Promise<void> {
|
async function openCreateForm(): Promise<void> {
|
||||||
createExpanded = true;
|
createExpanded = true;
|
||||||
createError = null;
|
createError = null;
|
||||||
@@ -121,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="memory-header-actions">
|
<div class="memory-header-actions">
|
||||||
{#if data.subjects.data}
|
{#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}
|
{/if}
|
||||||
<button
|
<button
|
||||||
bind:this={createButton}
|
bind:this={createButton}
|
||||||
@@ -209,14 +213,19 @@
|
|||||||
{#each subjects as subject (subject.id)}
|
{#each subjects as subject (subject.id)}
|
||||||
<a class="subject-row" href={subjectHref(subject.id)}>
|
<a class="subject-row" href={subjectHref(subject.id)}>
|
||||||
<div class="subject-copy">
|
<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>
|
<h2>{subject.role}</h2>
|
||||||
<code title={subject.id}>{subject.id}</code>
|
<code title={subject.id}>{subject.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<dl class="subject-meta">
|
<dl class="subject-meta">
|
||||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
<div>
|
||||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
<dt>Worker connection</dt>
|
||||||
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
<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>
|
</dl>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -465,8 +474,8 @@
|
|||||||
|
|
||||||
.subject-meta {
|
.subject-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-4);
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,12 +489,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subject-meta dd {
|
.subject-meta dd {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-compact);
|
font-size: var(--font-size-compact);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-meta dd strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.memory-state {
|
.memory-state {
|
||||||
padding: var(--space-5) 0;
|
padding: var(--space-5) 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
SubjektivSubjectCreateError,
|
SubjektivSubjectCreateError,
|
||||||
updateSubjektivSubjectBehavior,
|
updateSubjektivSubjectBehavior,
|
||||||
} from '#lib/workspace/memory/api.ts';
|
} 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';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -116,6 +116,24 @@
|
|||||||
function kindLabel(kind: string): string {
|
function kindLabel(kind: string): string {
|
||||||
return kind.replaceAll('_', ' ');
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -136,13 +154,50 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if subject}
|
{#if subject}
|
||||||
<dl class="subject-facts" aria-label="Subject details">
|
<section class="subject-overview" aria-label="Subject status">
|
||||||
<div><dt>Role</dt><dd>{subject.role}</dd></div>
|
<div>
|
||||||
<div><dt>State</dt><dd><span class="subject-state is-{subject.state}">{subject.state}</span></dd></div>
|
<span>Subject status</span>
|
||||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
|
||||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||||
<div><dt>Current worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
</div>
|
||||||
</dl>
|
<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}
|
{:else if data.subject.error}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
<strong>Subject details unavailable.</strong>
|
<strong>Subject details unavailable.</strong>
|
||||||
@@ -189,38 +244,40 @@
|
|||||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="memory-eyebrow">Resident context</p>
|
<p class="memory-eyebrow">Generated from committed Memory</p>
|
||||||
<h2 id="resident-surface-heading">Resident surface</h2>
|
<h2 id="resident-surface-heading">Resident context</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if surface}
|
{#if surface}
|
||||||
<span class="availability is-{surface.availability}">{surface.availability}</span>
|
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if surface?.availability === 'ready' && snapshot}
|
{#if surface?.availability === 'ready' && snapshot}
|
||||||
<div class="surface-meta">
|
<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>
|
<span aria-hidden="true">·</span>
|
||||||
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
|
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{#if snapshot.body_md.trim().length === 0}
|
{#if snapshot.body_md.trim().length === 0}
|
||||||
<div class="memory-state" role="status" data-surface-ready-empty>
|
<div class="memory-state" role="status" data-surface-ready-empty>
|
||||||
<strong>Resident surface is ready and empty.</strong>
|
<strong>Resident context is current but empty.</strong>
|
||||||
<p>This subject currently has no resident context to display.</p>
|
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<article class="surface-document" aria-label="Resident surface">
|
<article class="surface-document" aria-label="Resident context">
|
||||||
<DocumentMarkdown text={snapshot.body_md} />
|
<DocumentMarkdown text={snapshot.body_md} />
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{/if}
|
||||||
<details class="surface-details">
|
<details class="surface-details">
|
||||||
<summary>Surface provenance</summary>
|
<summary>Surface sources and diagnostics</summary>
|
||||||
<dl>
|
<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>
|
<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>
|
<dd>
|
||||||
{#if snapshot.memory_refs.length === 0}
|
{#if snapshot.memory_refs.length === 0}
|
||||||
None
|
None
|
||||||
@@ -237,26 +294,26 @@
|
|||||||
</details>
|
</details>
|
||||||
{:else if surface?.availability === 'ungenerated'}
|
{:else if surface?.availability === 'ungenerated'}
|
||||||
<div class="memory-state" role="status">
|
<div class="memory-state" role="status">
|
||||||
<strong>Resident surface has not been generated.</strong>
|
<strong>Resident context has not been generated.</strong>
|
||||||
<p>Committed Memories remain available below.</p>
|
<p>Committed Memories, if any, remain available below.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if surface?.availability === 'stale'}
|
{:else if surface?.availability === 'stale'}
|
||||||
<div class="memory-state is-warning" role="status">
|
<div class="memory-state is-warning" role="status">
|
||||||
<strong>Resident surface is stale.</strong>
|
<strong>Resident context needs to be refreshed.</strong>
|
||||||
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
|
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if surface?.availability === 'failed'}
|
{:else if surface?.availability === 'failed'}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
<strong>Resident surface generation failed.</strong>
|
<strong>Resident context generation failed.</strong>
|
||||||
<p>No failed or partial snapshot is displayed.</p>
|
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if data.surface.error}
|
{:else if data.surface.error}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
<strong>Resident surface unavailable.</strong>
|
<strong>Resident context status unavailable.</strong>
|
||||||
<p>{data.surface.error}</p>
|
<p>{data.surface.error}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -266,14 +323,14 @@
|
|||||||
<p class="memory-eyebrow">Committed records</p>
|
<p class="memory-eyebrow">Committed records</p>
|
||||||
<h2 id="current-memories-heading">Current Memories</h2>
|
<h2 id="current-memories-heading">Current Memories</h2>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{#if data.memories.data}
|
{#if data.memories.data}
|
||||||
{#if memories.length === 0}
|
{#if memories.length === 0}
|
||||||
<div class="memory-state" role="status" data-memories-empty>
|
<div class="memory-state" role="status" data-memories-empty>
|
||||||
<strong>No committed Memories.</strong>
|
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
|
||||||
<p>This subject has no current Memory revisions.</p>
|
<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}
|
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -289,9 +346,9 @@
|
|||||||
<p>{memory.excerpt || 'No excerpt.'}</p>
|
<p>{memory.excerpt || 'No excerpt.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
<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>Memory 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>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -384,25 +441,79 @@
|
|||||||
font-size: var(--font-size-compact);
|
font-size: var(--font-size-compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts {
|
.subject-overview {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
display: grid;
|
||||||
gap: var(--space-3);
|
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 {
|
.subject-overview > div {
|
||||||
display: block;
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts dt {
|
.subject-overview span,
|
||||||
white-space: normal;
|
.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 {
|
.subject-overview strong {
|
||||||
margin-top: var(--space-1);
|
color: var(--text-strong);
|
||||||
color: var(--text);
|
|
||||||
overflow-wrap: anywhere;
|
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,
|
.subject-state,
|
||||||
.availability,
|
.availability,
|
||||||
.memory-pill,
|
.memory-pill,
|
||||||
@@ -642,7 +753,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.subject-facts {
|
.subject-overview {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,10 +770,16 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts {
|
.subject-overview,
|
||||||
|
.subject-technical-details dl,
|
||||||
|
.surface-details dl {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-overview {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.memory-row {
|
.memory-row {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-9
@@ -63,6 +63,11 @@
|
|||||||
return value.replaceAll('_', ' ');
|
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 {
|
function rangeLabel(range: [number, number] | null | undefined): string {
|
||||||
return range ? `${range[0]}–${range[1]}` : 'None';
|
return range ? `${range[0]}–${range[1]}` : 'None';
|
||||||
}
|
}
|
||||||
@@ -98,8 +103,8 @@
|
|||||||
<header class="memory-page-header">
|
<header class="memory-page-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="pill-row">
|
<div class="pill-row">
|
||||||
<span class="memory-pill is-{memory.state}">{memory.state}</span>
|
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
|
||||||
<span class="memory-kind">{label(memory.kind)}</span>
|
<span class="memory-kind">{titleLabel(memory.kind)}</span>
|
||||||
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
||||||
@@ -109,11 +114,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl class="memory-facts" aria-label="Memory details">
|
<dl class="memory-facts" aria-label="Memory details">
|
||||||
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||||
<div><dt>State</dt><dd>{memory.state}</dd></div>
|
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
|
||||||
<div><dt>Kind</dt><dd>{label(memory.kind)}</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>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>
|
</dl>
|
||||||
|
|
||||||
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
|
<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">
|
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
|
||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
|
<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>
|
</header>
|
||||||
|
|
||||||
{#if memory.source_candidates.length === 0}
|
{#if memory.source_candidates.length === 0}
|
||||||
@@ -226,7 +231,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
<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 data.revisions.data}
|
||||||
{#if revisions.length === 0}
|
{#if revisions.length === 0}
|
||||||
<div>
|
<div>
|
||||||
@@ -238,7 +247,7 @@
|
|||||||
{#each revisions as revision (revision.revision)}
|
{#each revisions as revision (revision.revision)}
|
||||||
<li class:current={revision.revision === memory.revision}>
|
<li class:current={revision.revision === memory.revision}>
|
||||||
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
|
<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>
|
<p>{revision.claim}</p>
|
||||||
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
||||||
</a>
|
</a>
|
||||||
@@ -386,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.memory-facts {
|
.memory-facts {
|
||||||
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
@@ -414,6 +424,7 @@
|
|||||||
.why-useful,
|
.why-useful,
|
||||||
.staleness,
|
.staleness,
|
||||||
.change-reason,
|
.change-reason,
|
||||||
|
.section-intro,
|
||||||
.empty-copy,
|
.empty-copy,
|
||||||
.bounded-note {
|
.bounded-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ test("renders the subject index as the Memory product entry", () => {
|
|||||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("subject-1")).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"))
|
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||||
.toBe("/w/workspace-1/memory");
|
.toBe("/w/workspace-1/memory");
|
||||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
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",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
cursor: "memory-current",
|
cursor: "memory-current",
|
||||||
subject: result(subject()),
|
subject: result({
|
||||||
|
...subject(),
|
||||||
|
current_worker: { display_name: "Release Worker" },
|
||||||
|
}),
|
||||||
surface: result({
|
surface: result({
|
||||||
subject_id: "subject-1",
|
subject_id: "subject-1",
|
||||||
availability: "ready" as const,
|
availability: "ready" as const,
|
||||||
@@ -298,9 +304,15 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
.textContent,
|
.textContent,
|
||||||
)
|
)
|
||||||
.toContain("Prefer explicit evidence.");
|
.toContain("Prefer explicit evidence.");
|
||||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||||
.toBeNull();
|
.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,
|
0,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
|
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();
|
).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 () => {
|
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||||
const base = {
|
const base = {
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
@@ -600,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
expect(screen.getByText("Resident surface is ready and empty.")).not
|
expect(screen.getByText("Resident context is current but empty.")).not
|
||||||
.toBeNull();
|
.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(
|
await view.rerender(
|
||||||
{
|
{
|
||||||
@@ -614,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
},
|
},
|
||||||
} as never,
|
} 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(
|
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");
|
expect(screen.getByRole("alert").textContent).toContain("generation failed");
|
||||||
|
|
||||||
await view.rerender({ data: { ...base, surface: result(null) } } as never);
|
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();
|
.toBeNull();
|
||||||
|
|
||||||
await view.rerender(
|
await view.rerender(
|
||||||
@@ -639,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
} as never,
|
} as never,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("alert").textContent).toContain(
|
expect(screen.getByRole("alert").textContent).toContain(
|
||||||
"Resident surface unavailable",
|
"Resident context status unavailable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1663,45 +1663,6 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="console-shell worker-console-shell">
|
<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}
|
{#if rewindTargets.length > 0}
|
||||||
<section class="card rewind-targets" aria-label="Rewind targets">
|
<section class="card rewind-targets" aria-label="Rewind targets">
|
||||||
<h3>Rewind targets</h3>
|
<h3>Rewind targets</h3>
|
||||||
@@ -2106,6 +2067,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -2131,14 +2114,6 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-header {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-card {
|
.console-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -2146,46 +2121,59 @@
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-header-actions {
|
.console-view-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
gap: var(--space-1);
|
||||||
flex-wrap: wrap;
|
min-height: var(--space-5);
|
||||||
gap: var(--space-2);
|
padding: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: var(--space-1);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: var(--space-1) var(--space-2);
|
font-family: var(--font-sans);
|
||||||
font: inherit;
|
|
||||||
font-size: var(--font-size-compact);
|
font-size: var(--font-size-compact);
|
||||||
line-height: var(--line-height-compact);
|
line-height: var(--line-height-compact);
|
||||||
font-weight: 700;
|
font-weight: 400;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-view-modes button + button {
|
.console-view-toggle:hover {
|
||||||
border-left: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-view-modes button:hover {
|
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-view-modes button.active {
|
.console-view-toggle:focus-visible {
|
||||||
background: var(--accent);
|
outline: 1px solid var(--text-strong);
|
||||||
color: var(--bg);
|
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 {
|
.rewind-targets {
|
||||||
@@ -2684,14 +2672,5 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(0, 1fr) 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>
|
</style>
|
||||||
|
|||||||
+18
-3
@@ -261,13 +261,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
|
|||||||
await screen.findByText("Start of conversation");
|
await screen.findByText("Start of conversation");
|
||||||
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
||||||
expect(screen.getAllByText("searched 1 time・ran 1 command")).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 transcript = screen.getByRole("article", { name: "main transcript" });
|
||||||
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
|
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
|
||||||
expect(ids).toHaveLength(3);
|
expect(ids).toHaveLength(3);
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
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(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
||||||
|
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -473,7 +488,7 @@ test("mini task summary opens and closes details without a header Tasks button",
|
|||||||
latestListener().onFrame(subscribedFrame(session));
|
latestListener().onFrame(subscribedFrame(session));
|
||||||
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
|
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
|
||||||
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
|
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" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
|
||||||
const details = await screen.findByRole("complementary", { name: "Worker detail" });
|
const details = await screen.findByRole("complementary", { name: "Worker detail" });
|
||||||
expect(details.textContent).not.toContain("Capabilities");
|
expect(details.textContent).not.toContain("Capabilities");
|
||||||
|
|||||||
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
|||||||
entrypoints: [],
|
entrypoints: [],
|
||||||
}, fetcher);
|
}, 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",
|
||||||
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
"/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/entries/profiles%2Fmain.dcdl",
|
||||||
"/api/w/w%2Fone/config/source-tree/commit",
|
"/api/w/w%2Fone/config/source-tree/commit",
|
||||||
]);
|
]);
|
||||||
assertEquals(calls[3].init?.method, "POST");
|
assertEquals<unknown>(calls[3].init?.method, "POST");
|
||||||
assert(
|
assert(
|
||||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
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 () => {
|
Deno.test("config source API rejects unknown response fields", async () => {
|
||||||
const fetcher =
|
const fetcher =
|
||||||
(() =>
|
(() =>
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ async function assertSubjectCreateRejects(
|
|||||||
assertEquals(error.status, expectedStatus);
|
assertEquals(error.status, expectedStatus);
|
||||||
if (!error.message.includes(expectedMessage)) {
|
if (!error.message.includes(expectedMessage)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`expected ${JSON.stringify(error.message)} to include ${
|
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
|
||||||
JSON.stringify(expectedMessage)
|
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
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") {
|
function queryItem(id = "memory-1") {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -190,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
|||||||
() =>
|
() =>
|
||||||
parseSubjektivSubjectListResponse({
|
parseSubjektivSubjectListResponse({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
items: Array.from({ length: 101 }, (_, index) =>
|
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
||||||
subject(`subject-${index}`)),
|
|
||||||
has_more: false,
|
has_more: false,
|
||||||
}),
|
}),
|
||||||
"bounded array",
|
"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", () => {
|
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||||
const response = { limit: 100, items: [subject()], has_more: false };
|
const response = { limit: 100, items: [subject()], has_more: false };
|
||||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||||
@@ -401,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
|||||||
throw new TypeError("connection reset");
|
throw new TypeError("connection reset");
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
await assertSubjectCreateRejects(
|
await assertSubjectCreateRejects(
|
||||||
() =>
|
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||||
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
"may have been created",
|
"may have been created",
|
||||||
);
|
);
|
||||||
@@ -506,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
|
|||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseSubjektivMemoryQueryResponse({
|
parseSubjektivMemoryQueryResponse({
|
||||||
items: Array.from({ length: 101 }, (_, index) =>
|
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
||||||
queryItem(`memory-${index}`)),
|
|
||||||
has_more: false,
|
has_more: false,
|
||||||
}),
|
}),
|
||||||
"bounded array",
|
"bounded array",
|
||||||
|
|||||||
Reference in New Issue
Block a user