23 changed files with 950 additions and 328 deletions
+54
View File
@@ -279,4 +279,58 @@ mod tests {
assert_eq!(grep.matched_files, 2);
assert!(!grep.output.contains("c.txt"));
}
#[test]
fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("first.txt"),
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
)
.unwrap();
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let grep = run_grep(
&root,
root.clone(),
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::root(),
glob: Some("*.txt".to_string()),
output_mode: GrepOutputMode::Content,
case_insensitive: false,
before_context: 1,
after_context: 1,
multiline: false,
file_type: None,
limit: 20,
offset: 0,
},
&readable,
)
.unwrap();
assert_eq!(grep.match_count, 3);
assert_eq!(grep.matched_files, 2);
assert_eq!(
grep.output,
concat!(
"first.txt\n",
" 1 │ before\n",
" > 2 │ needle one\n",
" 3 │ after\n",
"\n",
" 6 │ before distant\n",
" > 7 │ needle distant\n",
" 8 │ after distant\n",
"\n",
"second.txt\n",
" > 1 │ needle two\n",
)
);
assert_eq!(grep.output.matches("first.txt").count(), 1);
assert_eq!(grep.output.matches("second.txt").count(), 1);
}
}
+49 -14
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::FsAccessPolicy;
@@ -57,20 +59,11 @@ impl GrepReport {
}
}
GrepOutputMode::Content => {
for line in &self.lines {
let separator = if line.is_match { ':' } else { '-' };
let path = logical_display(root, &line.path);
if self.show_line_numbers
&& let Some(number) = line.line_number
{
output.push_str(&format!(
"{path}{separator}{number}{separator}{}\n",
line.text
));
} else {
output.push_str(&format!("{path}{separator}{}\n", line.text));
}
}
output.push_str(&render_content_lines(
root,
&self.lines,
self.show_line_numbers,
));
}
}
GrepResult {
@@ -82,6 +75,48 @@ impl GrepReport {
}
}
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
for line in lines {
grouped.entry(&line.path).or_default().push(line);
}
let mut output = String::new();
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
if file_index > 0 {
output.push('\n');
}
let _ = writeln!(output, "{}", logical_display(root, path));
let number_width = file_lines
.iter()
.filter_map(|line| line.line_number)
.map(|number| number.to_string().len())
.max()
.unwrap_or(1);
let mut previous_line_end = None;
for line in file_lines {
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
&& number > previous_end
{
let _ = writeln!(output, "");
}
let marker = if line.is_match { '>' } else { ' ' };
if show_line_numbers && let Some(number) = line.line_number {
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
} else {
let _ = writeln!(output, " {marker} │ {}", line.text);
}
previous_line_end = line
.line_number
.map(|number| number + line.text.split('\n').count() as u64);
}
}
output
}
fn logical_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
+19 -2
View File
@@ -946,7 +946,7 @@ fn apply_role_profile(
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator"),
"enabled": slug == "orchestrator",
"direct_spawn": slug != "orchestrator"
});
value["feature"]["manage_workdir"] = serde_json::json!({
@@ -1408,7 +1408,22 @@ mod tests {
}
#[test]
fn builtin_companion_can_manage_workdirs() {
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
ProfileResolveOptions::with_worker_name("coder-worker"),
)
.unwrap();
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
@@ -1419,6 +1434,8 @@ mod tests {
.unwrap();
assert!(resolved.manifest.feature.manage_workdir.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
+1 -1
View File
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
match ticket.meta.resource_key {
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
Some(_) => Err(ToolError::ExecutionFailed(format!(
"{tool_name} failed: required Ticket human key is unavailable"
"{tool_name} failed: required Ticket key is unavailable"
))),
None => Ok(ticket.meta.id),
}
+1 -1
View File
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep")
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
session: session.clone(),
-8
View File
@@ -998,14 +998,6 @@ where
if feature_config.sub_worker.enabled {
worker.register_worker_orchestration_instruction();
if !feature_config.worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
}
let host_worker_observation_provider = worker.worker_observation_provider();
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
.and_then(serde_json::Value::as_str)
.filter(|key| is_canonical_resource_key(key, "T-"))
.map(ToOwned::to_owned)
.ok_or_else(|| {
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
})
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
}
fn objective_url(&self, id: &str) -> String {
@@ -295,7 +293,7 @@ fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
if !is_canonical_resource_key(&response.resource_key, "O-") {
return Err(ToolError::ExecutionFailed(
"required O- human key is unavailable".to_string(),
"required O- key is unavailable".to_string(),
));
}
let projected = serde_json::json!({
@@ -712,7 +710,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_show_summary_uses_projected_human_key() {
async fn objective_show_summary_uses_projected_key() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
@@ -764,7 +762,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
async fn objective_link_summaries_resolve_internal_ticket_ids_to_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
@@ -833,7 +831,7 @@ mod tests {
}
#[test]
fn objective_output_rejects_noncanonical_human_keys() {
fn objective_output_rejects_noncanonical_keys() {
let response = ObjectiveDetail {
resource_key: "O-internal".to_string(),
title: "Objective".to_string(),
@@ -228,7 +228,7 @@ pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryRespo
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
let item = object(value, "Ticket query item")?;
Ok(ModelTicketQueryItem {
ticket: human_ref(item, "resource_key", "T-")?,
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
readiness: optional_string(item, "readiness")?,
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
.transpose()?,
linked_objectives: string_array(item, "linked_objective_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "O-"))
.map(|key| validate_resource_ref(key, "O-"))
.collect::<Result<Vec<_>, _>>()?,
relation_count: usize_field(item, "relation_count")?,
blocker_count: usize_field(item, "blocker_count")?,
@@ -273,7 +273,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail {
ticket: human_ref(root, "resource_key", "T-")?,
ticket: resource_ref(root, "resource_key", "T-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
@@ -332,10 +332,10 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
let item = object(value, "Objective query item")?;
let linked_tickets = string_array(item, "linked_ticket_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "T-"))
.map(|key| validate_resource_ref(key, "T-"))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelObjectiveQueryItem {
objective: human_ref(item, "resource_key", "O-")?,
objective: resource_ref(item, "resource_key", "O-")?,
title: string_field(item, "title")?,
summary: optional_string(item, "snippet")?,
state: string_field(item, "state")?,
@@ -349,7 +349,7 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
let root = object(&value, "Objective detail response")?;
Ok(ModelObjectiveDetail {
objective: human_ref(root, "resource_key", "O-")?,
objective: resource_ref(root, "resource_key", "O-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
@@ -373,7 +373,7 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary {
worker: human_ref(worker, "worker_resource_key", "W-")?,
worker: resource_ref(worker, "worker_resource_key", "W-")?,
})
}
@@ -439,7 +439,7 @@ fn project_relation(
None => optional_string(relation, "at")?,
};
Ok(ModelRelation {
ticket: human_ref(relation, ticket_key, "T-")?,
ticket: resource_ref(relation, ticket_key, "T-")?,
kind,
note,
created_at,
@@ -449,7 +449,7 @@ fn project_relation(
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
let blocker = object(value, "Ticket blocker")?;
Ok(ModelBlocker {
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?,
@@ -466,7 +466,7 @@ fn project_notice(value: &Value) -> Result<ModelNotice, String> {
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
let summary = object(value, "Objective summary")?;
Ok(ModelObjectiveSummary {
objective: human_ref(summary, "resource_key", "O-")?,
objective: resource_ref(summary, "resource_key", "O-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
@@ -475,7 +475,7 @@ fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, Str
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
let summary = object(value, "Ticket summary")?;
Ok(ModelTicketSummary {
ticket: human_ref(summary, "resource_key", "T-")?,
ticket: resource_ref(summary, "resource_key", "T-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
@@ -491,9 +491,7 @@ fn project_assignment(
let principal = match kind.as_str() {
"worker" => current_coder
.map(|coder| coder.worker.clone())
.ok_or_else(|| {
"Worker assignment is missing a Workspace human key projection".to_string()
})?,
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
"user" => "user".to_string(),
other => format!("source:{other}"),
@@ -645,23 +643,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
.collect()
}
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
fn resource_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
let value = object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
validate_human_ref(value, prefix)
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
validate_resource_ref(value, prefix)
}
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
fn validate_resource_ref(value: String, prefix: &str) -> Result<String, String> {
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
if valid {
Ok(value)
} else {
Err(format!("required {prefix} human key is unavailable"))
Err(format!("required {prefix} key is unavailable"))
}
}
@@ -671,7 +669,7 @@ mod tests {
use serde_json::json;
#[test]
fn objective_projection_exposes_only_human_resource_references() {
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
"id": "00001M10HW6BV",
"resource_key": "O-543",
@@ -779,9 +777,9 @@ mod tests {
}
#[test]
fn human_resource_projection_rejects_noncanonical_keys() {
fn resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
assert!(validate_human_ref(key.to_string(), prefix).is_err());
assert!(validate_resource_ref(key.to_string(), prefix).is_err());
}
}
+119 -24
View File
@@ -306,19 +306,32 @@ struct BackendTicketService {
backend: TicketToolBackend,
}
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
}
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
let resource_key = ticket
.meta
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key))
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
Ok(TicketHandoff {
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
}
impl TicketService for BackendTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
let ticket = self.backend.show(ticket_ref.into())?;
let resource_key = ticket
.meta
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key))
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
Ok(TicketHandoff {
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
}
}
impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
}
}
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
backend: backend.clone(),
});
let ticket_service: Arc<dyn TicketService> = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(client.clone()),
}),
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
backend: backend.clone(),
}),
};
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
Self::invoke_client(client, workspace_id, operation)
}
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
let client = self.client.clone();
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
let request = move || {
Self::request_unprojected(
client,
WorkspaceRequestMethod::Get,
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
None,
)
};
if tokio::runtime::Handle::try_current().is_ok() {
return std::thread::spawn(request).join().map_err(|_| {
TicketError::Conflict("ticket REST request thread panicked".to_string())
})?;
}
request()
}
fn ticket_path(id: &TicketIdOrSlug) -> String {
let value = match id {
TicketIdOrSlug::Id(value)
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let mut value = Self::request_value(client, method, endpoint, body)?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_unprojected<T: serde::de::DeserializeOwned>(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let value = Self::request_value(client, method, endpoint, body)?;
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_value(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<Value> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
response.status
)));
}
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
TicketError::Conflict(format!("decode ticket REST response: {error}"))
})?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
serde_json::from_str(&response.body)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
.ok_or_else(|| {
TicketError::Conflict("required Ticket human key is unavailable".to_string())
})
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
}
fn request_unit(
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
.is_some_and(is_canonical_ticket_resource_key)
{
return Err(TicketError::Conflict(
"required Ticket human key is unavailable".to_string(),
"required Ticket key is unavailable".to_string(),
));
}
Ok(TicketBackendOperationResult::Ticket(ticket))
@@ -1861,7 +1916,7 @@ provider = "github"
}
#[test]
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
fn workspace_http_backend_records_relation_with_authoritative_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
@@ -2000,6 +2055,46 @@ provider = "github"
assert_eq!(removed.target, "T-2");
}
#[test]
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
let temp = TempDir::new().unwrap();
let local = LocalTicketBackend::new(temp.path().join("tickets"));
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
ticket.meta.resource_key = Some("T-548".to_string());
ticket.meta.workflow_state = TicketWorkflowState::Queued;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let response_body = serde_json::to_string(&ticket).unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
});
let service = WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
)),
};
let handoff = service.ticket_handoff("T-548").unwrap();
server.join().unwrap();
assert_eq!(handoff.id, created.id);
assert_eq!(handoff.resource_key, "T-548");
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
}
#[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482"));
+9
View File
@@ -4449,6 +4449,13 @@ mod tests {
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
.unwrap();
assert!(companion.feature.manage_workdir.enabled);
assert!(companion.feature.sub_worker.enabled);
assert!(!companion.feature.worker.enabled);
let coder = archive
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
.unwrap();
assert!(coder.feature.sub_worker.enabled);
assert!(!coder.feature.worker.enabled);
}
#[test]
@@ -4475,6 +4482,8 @@ mod tests {
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
.unwrap();
assert_eq!(manifest.worker.name, "remote-test-worker");
assert!(manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.worker.enabled);
}
#[test]
+192 -23
View File
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
}
fn resolve_workspace_ticket_identity(
api: &WorkspaceApi,
workspace_id: &str,
reference: &str,
) -> ApiResult<String> {
let ticket_id = resolve_workspace_ticket_reference(api, workspace_id, reference)?;
let ticket = browser_ticket_backend(api)?
.show(ticket_id.clone().into())
.map_err(Error::from)?;
if ticket.meta.id != ticket_id {
return Err(Error::InvalidInput(
"resolved Ticket identity does not match Ticket authority".to_string(),
)
.into());
}
Ok(ticket_id)
}
fn resolve_workspace_worker_ticket_assignment(
api: &WorkspaceApi,
workspace_id: &str,
assignment: &mut Option<CreateWorkspaceWorkerTicketAssignmentRequest>,
) -> ApiResult<()> {
if let Some(assignment) = assignment {
assignment.ticket_id =
resolve_workspace_ticket_identity(api, workspace_id, &assignment.ticket_id)?;
}
Ok(())
}
#[derive(Debug, serde::Deserialize)]
struct MergeRequestListHttpQuery {
state: Option<String>,
@@ -8217,6 +8247,11 @@ async fn spawn_known_worker(
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
resolve_workspace_worker_ticket_assignment(
&api,
&path.workspace_id,
&mut request.ticket_assignment,
)?;
let relation = if request.ticket_assignment.is_some() {
"assigned"
} else {
@@ -9261,9 +9296,8 @@ fn cleanup_working_directory_for_runtime(
result.diagnostics,
));
};
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?;
let mut summary = working_directory.summary;
persist_workdir_cleanup_observation(&api, runtime_id, &summary)?;
apply_workdir_occupancy_projection(&api, &mut summary)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@@ -14154,11 +14188,7 @@ fn sync_runtime_workdir_observations(
api.store.upsert_workdir_registry(&updated)?;
}
} else {
record.materialization_status =
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
record.cleanliness = "unknown".to_string();
record.updated_at = now_registry_timestamp();
api.store.upsert_workdir_registry(&record)?;
persist_workdir_runtime_miss(api, record, result.diagnostics.as_slice())?;
}
}
Err(_) => {
@@ -14172,17 +14202,54 @@ fn sync_runtime_workdir_observations(
Ok(response.diagnostics)
}
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
if diagnostics
fn persist_workdir_cleanup_observation(
api: &WorkspaceApi,
runtime_id: &str,
summary: &WorkingDirectorySummary,
) -> ApiResult<()> {
if summary.status == WorkingDirectoryStatusKind::NotFound {
api.store.delete_workdir_registry(
&api.config.workspace_id,
summary.working_directory_id.as_str(),
)?;
} else {
let record = workdir_record_from_summary(api, runtime_id, summary);
api.store.upsert_workdir_registry(&record)?;
}
Ok(())
}
fn workdir_runtime_miss_is_not_found(diagnostics: &[RuntimeDiagnostic]) -> bool {
diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
{
}
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
if workdir_runtime_miss_is_not_found(diagnostics) {
"not_found"
} else {
"unknown"
}
}
fn persist_workdir_runtime_miss(
api: &WorkspaceApi,
mut record: WorkdirRegistryRecord,
diagnostics: &[RuntimeDiagnostic],
) -> ApiResult<()> {
if workdir_runtime_miss_is_not_found(diagnostics) {
api.store
.delete_workdir_registry(&api.config.workspace_id, record.workdir_id.as_str())?;
} else {
record.materialization_status = "unknown".to_string();
record.cleanliness = "unknown".to_string();
record.updated_at = now_registry_timestamp();
api.store.upsert_workdir_registry(&record)?;
}
Ok(())
}
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
let mut diagnostics = Vec::new();
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
@@ -16980,22 +17047,24 @@ mod tests {
#[test]
fn workdir_runtime_miss_uses_exact_typed_code() {
let typed_not_found = [RuntimeDiagnostic {
code: "working_directory_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "missing".to_string(),
}];
assert_eq!(
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
code: "working_directory_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "missing".to_string(),
}]),
workdir_status_from_runtime_miss(&typed_not_found),
"not_found"
);
assert_eq!(
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
code: "some_other_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "not a typed workdir miss".to_string(),
}]),
"unknown"
);
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
let unrelated = [RuntimeDiagnostic {
code: "some_other_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "not a typed workdir miss".to_string(),
}];
assert_eq!(workdir_status_from_runtime_miss(&unrelated), "unknown");
assert!(!workdir_runtime_miss_is_not_found(&unrelated));
}
struct DeterministicExecutionBackend {
@@ -20186,6 +20255,25 @@ mod tests {
.unwrap(),
ticket_id
);
assert_eq!(
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_resource_key)
.unwrap(),
ticket_id
);
assert_eq!(
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_id).unwrap(),
ticket_id
);
let mut assignment = Some(CreateWorkspaceWorkerTicketAssignmentRequest {
ticket_id: ticket_resource_key.clone(),
operation_id: "ticket-key-assignment".to_string(),
});
resolve_workspace_worker_ticket_assignment(&api, TEST_WORKSPACE_ID, &mut assignment)
.unwrap();
assert_eq!(assignment.unwrap().ticket_id, ticket_id);
let missing =
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, "T-999999").unwrap_err();
assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND);
let path = || ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_id.clone(),
@@ -21308,6 +21396,87 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn confirmed_runtime_miss_removes_registry_record_but_unknown_is_retained() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
seed_cleanup_workdir(&api, "deleted-workdir", "present", "clean");
let deleted = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
.unwrap()
.unwrap();
persist_workdir_runtime_miss(
&api,
deleted,
&[RuntimeDiagnostic {
code: "working_directory_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "missing".to_string(),
}],
)
.unwrap();
assert!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
.unwrap()
.is_none()
);
seed_cleanup_workdir(&api, "unknown-workdir", "present", "clean");
let unknown = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
.unwrap()
.unwrap();
persist_workdir_runtime_miss(
&api,
unknown,
&[RuntimeDiagnostic {
code: "runtime_unavailable".to_string(),
severity: DiagnosticSeverity::Warning,
message: "temporarily unavailable".to_string(),
}],
)
.unwrap();
assert_eq!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
.unwrap()
.unwrap()
.materialization_status,
"unknown"
);
}
#[tokio::test]
async fn cleanup_not_found_observation_removes_registry_record() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let working_directory_id = "cleanup-existing";
seed_cleanup_workdir(&api, working_directory_id, "present", "clean");
let record = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
.unwrap()
.unwrap();
let mut summary = workdir_summary_from_record(&record);
summary.status = WorkingDirectoryStatusKind::NotFound;
persist_workdir_cleanup_observation(&api, "runtime-test", &summary).unwrap();
assert!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
.unwrap()
.is_none()
);
}
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
api.store
@@ -19,7 +19,7 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
- rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
-1
View File
@@ -10,7 +10,6 @@ import "./base.dcdl" // {
web = { enabled = true; };
sub_worker = { enabled = true; };
flow = { enabled = true; };
worker = { enabled = true; };
ticket = { enabled = true; thread = true; };
merge_request = {
show = true;
-1
View File
@@ -8,7 +8,6 @@ import "./base.dcdl" // {
memory = { enabled = true; };
web = { enabled = true; };
sub_worker = { enabled = true; };
worker = { enabled = true; };
manage_workdir = { enabled = true; };
ticket = { enabled = true; authoring = true; thread = true; };
};
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
+22 -12
View File
@@ -9,9 +9,9 @@
"npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
"npm:@lezer/highlight@1.2.3": "1.2.3",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
"npm:clsx@2.1.1": "2.1.1",
"npm:cookie@0.6.0": "0.6.0",
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
@@ -23,7 +23,8 @@
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
"npm:svelte@5.45.6": "5.45.6",
"npm:typescript@5.9.3": "5.9.3",
"npm:vite@7.2.7": "7.2.7"
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
"npm:yaml@2.9.0": "2.9.0"
},
"jsr": {
"@std/assert@1.0.19": {
@@ -433,13 +434,13 @@
"acorn"
]
},
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"dependencies": [
"@sveltejs/kit"
]
},
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
"dependencies": [
"@standard-schema/spec",
@@ -465,7 +466,7 @@
],
"bin": true
},
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": {
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
@@ -474,7 +475,7 @@
"vite"
]
},
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": {
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
@@ -966,7 +967,7 @@
"vfile-message"
]
},
"vite@7.2.7": {
"vite@7.2.7_yaml@2.9.0": {
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dependencies": [
"esbuild",
@@ -974,14 +975,18 @@
"picomatch",
"postcss",
"rollup",
"tinyglobby"
"tinyglobby",
"yaml"
],
"optionalDependencies": [
"fsevents"
],
"optionalPeers": [
"yaml"
],
"bin": true
},
"vitefu@1.1.2_vite@7.2.7": {
"vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
"dependencies": [
"vite"
@@ -993,6 +998,10 @@
"w3c-keyname@2.2.8": {
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"yaml@2.9.0": {
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"bin": true
},
"zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
},
@@ -1024,7 +1033,8 @@
"packageJson": {
"dependencies": [
"npm:@fontsource/ibm-plex-mono@5.3.0",
"npm:gen-interface-jp@0.8.0"
"npm:gen-interface-jp@0.8.0",
"npm:yaml@2.9.0"
]
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"gen-interface-jp": "0.8.0"
"gen-interface-jp": "0.8.0",
"yaml": "2.9.0"
}
}
@@ -8,6 +8,7 @@
};
let { item }: Props = $props();
let detailOpen = $state(false);
let nowMs = $state(Date.now());
$effect(() => {
@@ -55,22 +56,20 @@
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
}
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
const [firstLine = '', ...rest] = line.body.split('\n');
const [label, suffix = ''] = firstLine.split(' — ', 2);
return {
label,
suffix,
rest: rest.join('\n')
};
function toolLabel(line: ConsoleLine): string {
return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
}
function toolStatus(line: ConsoleLine): string {
return line.toolStatus ?? line.toolCall?.state ?? '';
}
function shouldRenderMarkdown(line: ConsoleLine): boolean {
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
}
function bodyTextAfterToolSummary(line: ConsoleLine): string {
return toolSummary(line).rest;
function toolBodyText(line: ConsoleLine): string {
return detailOpen ? (line.expandedBody ?? line.body) : line.body;
}
</script>
@@ -107,20 +106,27 @@
</div>
{:else if item.kind === 'tool'}
<div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span>
<span class="tool-separator"></span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
<span class="tool-label">{toolLabel(item)}</span>
<span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
{#if item.detail}
<button
type="button"
class="tool-detail-button"
aria-expanded={detailOpen}
onclick={() => (detailOpen = !detailOpen)}
>detail</button>
{/if}
</div>
{/if}
{#if item.compaction}
<!-- rendered as one lifecycle item above -->
{:else if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)}
{#if toolBodyText(item)}
<p class="console-plain-text">
{#if isBashTool(item)}
<AnsiText text={bodyTextAfterToolSummary(item)} />
<AnsiText text={toolBodyText(item)} />
{:else}
{bodyTextAfterToolSummary(item)}
{toolBodyText(item)}
{/if}
</p>
{/if}
@@ -147,11 +153,10 @@
{/each}
</div>
{/if}
{#if item.detail}
<details class="message-detail">
<summary>detail</summary>
{#if item.detail && detailOpen}
<div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
<p>{item.detail}</p>
</details>
</div>
{/if}
</li>
@@ -306,48 +311,46 @@
.tool-summary {
display: flex;
align-items: baseline;
gap: 0;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.88rem;
font-weight: 750;
}
.tool-label {
flex: 0 0 auto;
color: var(--tui-cyan);
white-space: nowrap;
}
.tool-separator {
flex: 0 0 auto;
white-space: nowrap;
}
.tool-suffix {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
overflow: hidden;
color: var(--tui-cyan);
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-separator,
.tool-suffix {
.tool-status {
flex: 0 0 auto;
color: var(--tui-dark-gray);
font-size: 0.72rem;
white-space: nowrap;
}
.tool-state-error .tool-suffix {
.tool-state-error .tool-status {
color: var(--tui-red);
}
.tool-state-running .tool-suffix,
.tool-state-streaming_args .tool-suffix,
.tool-state-pending .tool-suffix {
.tool-state-running .tool-status,
.tool-state-streaming_args .tool-status,
.tool-state-pending .tool-status {
color: var(--tui-yellow);
}
.tool-state-done .tool-suffix {
.tool-state-done .tool-status {
color: var(--tui-dark-gray);
}
.console-line.error-line .tool-status {
color: var(--tui-red);
}
.message-heading {
display: flex;
align-items: center;
@@ -412,13 +415,47 @@
color: var(--code);
}
.tool-detail-button {
margin-inline-start: auto;
border: 1px solid var(--line);
border-radius: 0.35rem;
padding: 0.08rem 0.35rem;
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 0.68rem;
font-weight: 750;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.console-line:hover .tool-detail-button,
.tool-detail-button:focus-visible,
.tool-detail-button[aria-expanded='true'] {
opacity: 1;
pointer-events: auto;
}
.message-detail {
margin-top: 0.35rem;
border-left: 2px solid var(--line);
padding-left: 0.6rem;
color: var(--text-muted);
font-size: 0.84rem;
}
.message-detail summary {
cursor: pointer;
font-weight: 800;
.message-detail p {
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
@media (hover: none) {
.tool-detail-button {
opacity: 1;
pointer-events: auto;
}
}
</style>
@@ -313,26 +313,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
!toolLines[0].streaming,
"completed tool call should not remain streaming",
);
assert(
toolLines[0].body.includes("$ pwd"),
"Bash command should be summarized",
);
assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
assertEquals(toolLines[0].toolStatus, "done");
assert(
toolLines[0].body.includes("/repo"),
"tool result should be folded into the Call block",
);
assert(
toolLines[0].body.includes("line9"),
"Bash result preview should include the ninth output line",
"Bash preview should include the ninth output line",
);
assert(
!toolLines[0].body.includes("line10") &&
!toolLines[0].body.includes("line12"),
"Bash result preview should be capped at ten display lines",
toolLines[0].body.includes("… +3 more lines"),
"Bash preview should retain its line cap",
);
assert(
toolLines[0].body.includes("… +3 more lines"),
"Bash result preview should show omitted output count",
toolLines[0].expandedBody?.includes("line12") === true &&
!toolLines[0].expandedBody?.includes("more lines"),
"Bash detail should show every returned output line",
);
assert(
toolLines[0].detail?.includes("id: call-1"),
@@ -421,7 +420,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
assertEquals(line.toolCallLabel, "Bash($ long-command)");
assertEquals(line.toolStatus, "failed (exit 7)");
assert(!line.body.includes("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("ready\n"), line.body);
@@ -463,7 +463,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assert(line.body.includes("Bash — running…"), line.body);
assertEquals(line.toolCallLabel, "Bash($ slow)");
assertEquals(line.toolStatus, "running…");
assert(!line.body.includes("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
@@ -474,7 +475,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
assertEquals(line.streaming, true);
});
Deno.test("projectConsole caps default tool request and result previews", () => {
Deno.test("projectConsole caps default preview but keeps complete detail body", () => {
const projection = projectConsole([
{
eventId: "70",
@@ -508,19 +509,100 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · CustomTool");
assertEquals(line.body.split("\n").length, 7);
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
assertEquals(line.toolStatus, "done");
assertEquals(line.body.split("\n").length, 3);
assert(
line.body.includes('"first": "one"'),
"request preview should be shown",
line.body.includes("out1") && line.body.includes("… +3 more lines"),
"normal display should retain the capped response preview",
);
assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail");
assert(
line.detail?.includes("arguments:\nfirst: one") === true &&
line.detail?.includes("fourth: four") === true,
"detail metadata should render complete request arguments as YAML",
);
assert(
line.expandedBody?.includes("out5") === true &&
!line.expandedBody?.includes("more lines"),
"detail body should contain the complete result",
);
assert(line.body.includes("out1"), "result preview should be shown");
assert(!line.body.includes("third"), "request preview should be capped");
assert(!line.body.includes("out3"), "result preview should be capped");
assert(line.body.includes("… +"), "overflow marker should be shown");
});
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
Deno.test("projectConsole renders JSON tool responses as YAML", () => {
const projection = projectConsole([
{
eventId: "json-call",
event: {
event: "tool_call_done",
data: {
id: "json-tool",
name: "CustomTool",
arguments: "{}",
},
} satisfies Event,
},
{
eventId: "json-result",
event: {
event: "tool_result",
data: {
id: "json-tool",
summary: "json completed",
output: JSON.stringify({
status: "ok",
items: [{ id: 1 }, { id: 2 }],
}),
is_error: false,
},
} satisfies Event,
},
{
eventId: "invalid-json-call",
event: {
event: "tool_call_done",
data: {
id: "invalid-json-tool",
name: "CustomTool",
arguments: "{}",
},
} satisfies Event,
},
{
eventId: "invalid-json-result",
event: {
event: "tool_result",
data: {
id: "invalid-json-tool",
summary: "invalid json",
output: '{"status": broken}',
is_error: false,
},
} satisfies Event,
},
]);
const toolLines = projection.lines.filter((line) => line.kind === "tool");
const jsonLine = toolLines.find((line) => line.id.includes("json-tool"));
const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool"));
assert(jsonLine, "JSON tool line should be projected");
assert(invalidLine, "invalid JSON tool line should be projected");
assert(
jsonLine.expandedBody?.includes("status: ok") === true &&
jsonLine.expandedBody?.includes(" - id: 2") === true,
"detail body should serialize parsed JSON as YAML",
);
assert(
jsonLine.body.includes("more lines"),
"normal preview should cap the pretty-printed JSON",
);
assert(
invalidLine.expandedBody?.includes('{"status": broken}') === true,
"invalid JSON-looking output should remain unchanged",
);
});
Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => {
const projection = projectConsole([
{
eventId: "72",
@@ -549,17 +631,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
assert(
line.body.includes("Grep — 6 matches"),
"Grep summary should be shown",
);
assert(line.body.includes("query: needle"), "Grep query should be shown");
assertEquals(line.toolCallLabel, "Grep(needle)");
assertEquals(line.toolStatus, "done");
assert(line.body.includes("hit1"), "first result should be shown");
assert(line.body.includes("hit5"), "fifth result should be shown");
assert(!line.body.includes("hit6"), "sixth result should be capped");
assert(!line.body.includes("hit6"), "normal preview should retain its result cap");
assert(
line.body.includes("… +1 more results"),
"overflow marker should be shown",
"preview should show the omitted result count",
);
assert(
line.expandedBody?.includes("hit6") === true &&
!line.expandedBody?.includes("more results"),
"detail body should show every Grep result",
);
});
@@ -594,17 +678,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
assert(
line.body.includes("Grep — Failed"),
"error suffix should stay short",
);
assertEquals(line.toolCallLabel, "Grep(needle)");
assertEquals(line.toolStatus, "error");
assert(
line.body.includes(message),
"error detail should remain visible in the body",
);
assert(
!line.body.includes(`Grep — ${message}`),
"error detail should not be repeated in the suffix",
!line.toolCallLabel?.includes(message),
"error detail should not be repeated in the Call signature",
);
});
@@ -879,9 +961,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
assertEquals(toolLines[0].toolStatus, "reading…");
assert(
toolLines[0].body.includes("/tmp/a.md") &&
toolLines[0].body.includes("Read — reading"),
toolLines[0].body.includes("/tmp/a.md"),
"Read call should render aggregate progress and path without content",
);
});
@@ -1018,10 +1101,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
const toolLines = projection.lines.filter((line) => line.kind === "tool");
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
assert(
toolLines[0].body.includes("Read — 2 files read"),
"aggregate count should be shown",
);
assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
assertEquals(toolLines[0].toolStatus, "done");
assert(
toolLines[0].body.includes("/tmp/a.md"),
"first path should be listed",
@@ -1073,7 +1154,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () =>
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Edit");
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)");
assertEquals(line.toolStatus, "done");
assertEquals(line.body, "ok");
assertEquals(line.diff?.map((row) => row.kind), [
"context",
"remove",
@@ -1242,13 +1325,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
assertEquals(projection.status, "running");
assertEquals(
projection.lines.map((line) =>
`${line.kind}:${line.body}:${line.streaming}`
`${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}`
),
[
"user:seed user:false",
"user:new user:false",
"assistant:assistant reply:false",
"tool:Read1 file read\n /tmp/a.md:false",
"tool:Read(1 file)\n /tmp/a.md:false",
"status:Compacting…:true",
"in_flight:partial:true",
],
@@ -1476,25 +1559,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
},
]);
const bodies = projection.lines.filter((line) => line.kind === "tool").map((
line,
) => line.body);
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
const toolLines = projection.lines.filter((line) => line.kind === "tool");
const bodies = toolLines.map((line) => line.body);
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
assertEquals(bodies[0], " src/main.rs");
assert(
projection.lines[0].detail?.includes("from src/main.rs"),
"Read summary detail path should be relative",
);
assert(
bodies.some((body) =>
body.includes("Writeout.txt") && body.includes("Wrote out.txt")
toolLines.some((line) =>
line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt")
),
"Write header and known result path should be relative",
"Write signature and known result path should be relative",
);
assert(
bodies.some((body) =>
body.includes("Editsrc/main.rs") && body.includes("Edited src/main.rs")
toolLines.some((line) =>
line.toolCallLabel === "Edit(src/main.rs)" &&
line.body.includes("Edited src/main.rs")
),
"Edit header and known result path should be relative",
"Edit signature and known result path should be relative",
);
assert(
bodies.some((body) =>
@@ -11,6 +11,7 @@ import type {
InternalWorkerSnapshot,
Segment,
} from "$lib/generated/protocol";
import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyRunActivityEvent,
@@ -86,6 +87,9 @@ export type ConsoleLine = {
kind: ConsoleLineKind;
title: string;
body: string;
expandedBody?: string;
toolCallLabel?: string;
toolStatus?: string;
detail?: string;
compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[];
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
title: item.title.startsWith("Call · Tool result")
? item.title
: `Call · ${toolCall.name}`,
body: renderToolCall(toolCall),
body: renderToolResponse(toolCall),
expandedBody: renderToolResponse(toolCall, true),
toolCallLabel: toolCallSignature(toolCall),
toolStatus: toolCallStatus(toolCall),
detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
@@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
};
}
function renderToolCall(toolCall: ToolCallView): string {
function renderToolResponse(toolCall: ToolCallView, expanded = false): string {
switch (toolCall.name) {
case "Read":
return renderReadTool(toolCall);
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
case "Glob":
return renderSearchTool(toolCall);
case "Grep":
return renderGrepTool(toolCall);
return renderGrepTool(toolCall, expanded);
case "Bash":
return renderBashTool(toolCall);
return renderBashTool(toolCall, expanded);
default:
return renderDefaultTool(toolCall);
return renderDefaultTool(toolCall, expanded);
}
}
function toolCallSignature(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
switch (toolCall.name) {
case "Read":
return `Read(${readPath(toolCall)})`;
case "Write":
case "Edit": {
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
return `${toolCall.name}(${path})`;
}
case "Glob":
return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
case "Grep":
return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
case "Bash": {
const command = stringField(args, "command");
return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`;
}
default:
return `${toolCall.name}(${genericCallArguments(toolCall)})`;
}
}
function genericCallArguments(toolCall: ToolCallView): string {
const raw = toolCall.arguments ?? toolCall.argsStream;
if (!raw.trim()) return "";
const parsed = parseJson(raw);
if (parsed === undefined) return singleLine(raw);
const serialized = JSON.stringify(parsed) ?? "null";
return isRecord(parsed) ? serialized.slice(1, -1) : serialized;
}
function singleLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function toolCallStatus(toolCall: ToolCallView): string {
return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state);
}
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
const result: ConsoleLine[] = [];
let index = 0;
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
const paths = calls.map(readPath);
const visiblePaths = inProgress ? paths.slice(-3) : paths;
const body = compactLines([
inProgress
? `Read — reading (${count} file${plural(count)}…)`
: `Read — ${count} file${plural(count)} read`,
visiblePaths.map((path) => ` ${path}`).join("\n"),
inProgress && paths.length > visiblePaths.length
? ` … (${paths.length - visiblePaths.length} earlier)`
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
kind: "tool",
title: "Call · Read",
body,
toolCallLabel: `Read(${count} file${plural(count)})`,
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
detail: calls.map(readDetail).join("\n\n"),
eventId: group.at(-1)?.eventId,
source: "event",
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
]);
}
function renderReadTool(toolCall: ToolCallView): string {
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
function renderReadTool(_toolCall: ToolCallView): string {
return "";
}
function renderWriteTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
const content = stringField(args, "content");
return compactLines([
`Write — ${path} (${stateSuffix(toolCall.state)})`,
cappedSection(content, 5),
knownToolResultText(toolCall),
]);
return knownToolResultText(toolCall) ?? "";
}
function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
const diff = editDiff(toolCall) ?? [];
const removes = diff.filter((line) => line.kind === "remove").length;
const adds = diff.filter((line) => line.kind === "add").length;
return compactLines([
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
knownToolResultText(toolCall),
]);
return knownToolResultText(toolCall) ?? "";
}
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
}
function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`${toolCall.name}${toolHeaderSuffix(toolCall, summary)}`,
knownToolResultText(toolCall),
]);
return knownToolResultText(toolCall) ?? "";
}
function renderGrepTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
grepQueryText(toolCall),
cappedResultSection(knownToolResultText(toolCall), 5),
]);
function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
const result = knownToolResultText(toolCall);
return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
}
function toolHeaderSuffix(
toolCall: ToolCallView,
summary?: string,
): string {
if (toolCall.state === "error") {
return "Failed";
function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
if (["done", "error"].includes(toolCall.state)) {
const result = resultText(toolCall);
return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
}
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
}
function grepQueryText(toolCall: ToolCallView): string | undefined {
const args = parsedArgs(toolCall);
const pattern = stringField(args, "pattern");
if (pattern) {
return `query: ${pattern}`;
}
const renderedArgs = argsText(toolCall);
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
}
function renderBashTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const command = stringField(args, "command");
return compactLines([
`Bash — ${commandStateSuffix(toolCall)}`,
command ? `$ ${command}` : argsText(toolCall),
["done", "error"].includes(toolCall.state)
? cappedDisplaySection(resultText(toolCall), 10)
: renderLiveCommandOutput(toolCall.command),
]);
return renderLiveCommandOutput(toolCall.command) ?? "";
}
function commandStateSuffix(toolCall: ToolCallView): string {
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
]);
}
function renderDefaultTool(toolCall: ToolCallView): string {
return compactLines([
`${toolCall.name}${stateSuffix(toolCall.state)}`,
cappedDisplaySection(argsText(toolCall), 3),
cappedDisplaySection(resultText(toolCall), 3),
]);
function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
const result = resultText(toolCall);
return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
}
function toolCallDetail(toolCall: ToolCallView): string {
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
}
function resultText(toolCall: ToolCallView): string | undefined {
if (toolCall.output) {
return toolCall.output;
const text = toolCall.output || toolCall.summary;
return text ? formatJsonResponseAsYaml(text) : undefined;
}
function formatJsonResponseAsYaml(text: string): string {
const trimmed = text.trim();
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return text;
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== "object") {
return text;
}
return stringifyYaml(parsed).trimEnd();
} catch {
return text;
}
return toolCall.summary;
}
function knownToolResultText(toolCall: ToolCallView): string | undefined {
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
return "";
}
const parsed = parseJson(raw);
return parsed === undefined ? raw : jsonPreview(parsed);
return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
}
function parsedArgs(
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array<string | undefined | null | false>): string {
return lines.filter((line): line is string => Boolean(line)).join("\n");
}
function cappedSection(
value: string | undefined,
cap: number,
): string | undefined {
if (!value) {
return undefined;
}
const lines = value.split(/\r?\n/);
const shown = lines.slice(0, cap);
if (lines.length > cap) {
shown.push(`… +${lines.length - cap} more lines`);
}
return shown.join("\n");
}
function cappedDisplaySection(
value: string | undefined,
maxLines: number,
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("`${ticketPath}/queue`") &&
ticketDetailPage.includes("const path = ticketPath") &&
ticketDetailPage.includes("`${path}/queue`") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("mergeRequest.review_status") &&
@@ -402,7 +403,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes("{#if isBashTool(item)}") &&
consoleLine.includes(
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
"<AnsiText text={toolBodyText(item)} />",
) &&
consoleLine.includes(
".console-line.tool-bash .console-plain-text",
@@ -419,6 +420,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
);
});
Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
assert(
consoleLine.includes(
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
) &&
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
consoleLine.includes('class={`tool-status') &&
consoleLine.includes('class="tool-detail-button"') &&
consoleLine.includes("aria-expanded={detailOpen}") &&
consoleLine.includes("detailOpen = !detailOpen") &&
consoleLine.includes("item.detail && detailOpen") &&
consoleLine.includes('role="region"') &&
consoleLine.includes(".console-line:hover .tool-detail-button") &&
consoleLine.includes(".tool-detail-button:focus-visible") &&
consoleLine.includes("@media (hover: none)") &&
!consoleLine.includes('<details class="message-detail">'),
"Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata",
);
});
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
@@ -36,7 +36,7 @@
const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data;
const loadedRepositories = $derived(data.repositories.data);
type QueueOutcome = {
requested_ticket: string;
@@ -62,6 +62,8 @@
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let cancellationReason = $state("");
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
let routeGeneration = 0;
const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
);
@@ -95,13 +97,43 @@
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = ticket.title;
editBody = ticket.body;
repositoryId = ticket.repository_id ?? "";
refSelector = ticket.ref_selector ?? "";
nextState = ticket.state;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
repositoryId = updatedTicket.repository_id ?? "";
refSelector = updatedTicket.ref_selector ?? "";
nextState = updatedTicket.state;
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
threadRole = "comment";
threadBody = "";
resolution = "";
busy = null;
errorMessage = null;
queueMessage = null;
readyOperationKey = null;
manualRuntimeId = "";
manualWorkerId = "";
cancellationReason = "";
}
$effect(() => {
const incomingTicketId = data.ticketId;
const incomingTicket = data.ticket.data;
if (!incomingTicket) return;
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
untrack(() => {
if (incomingSnapshot === routeTicketSnapshot) return;
routeTicketSnapshot = incomingSnapshot;
routeGeneration += 1;
resetTicketView(incomingTicket);
});
});
async function mutate(
action: string,
suffix: string,
@@ -109,40 +141,51 @@
method = "POST",
): Promise<boolean> {
if (busy) return false;
const generation = routeGeneration;
const path = `${ticketPath}${suffix}`;
busy = action;
errorMessage = null;
try {
const path = `${ticketPath}${suffix}`;
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (generation !== routeGeneration) return false;
applyTicket(response);
return true;
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
}
return false;
} finally {
busy = null;
if (generation === routeGeneration) busy = null;
}
}
async function queueTicket(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = "queue";
errorMessage = null;
queueMessage = null;
try {
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
`${ticketPath}/queue`,
`${path}/queue`,
{ method: "POST", body: JSON.stringify({}) },
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
if (generation !== routeGeneration) return;
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
applyTicket(updatedTicket);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
}
} finally {
busy = null;
if (generation === routeGeneration) busy = null;
}
}
@@ -152,11 +195,13 @@
principal: Record<string, string>,
): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = action;
errorMessage = null;
try {
await workspaceApiJsonWithBody(
`${ticketPath}/assignments/${role}`,
`${path}/assignments/${role}`,
{
method: "PUT",
body: JSON.stringify({
@@ -166,11 +211,16 @@
}),
},
);
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
}
} finally {
busy = null;
if (generation === routeGeneration) busy = null;
}
}
@@ -0,0 +1,50 @@
import { assert, assertStringIncludes } from "jsr:@std/assert";
const pageSource = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
Deno.test("ticket detail synchronizes reused route data", () => {
const effectStart = pageSource.indexOf("$effect(() => {");
assert(effectStart >= 0, "ticket detail must react to reused route props");
const effectSource = pageSource.slice(effectStart);
for (
const token of [
"data.ticketId",
"data.ticket.data",
"incomingTicket.item_revision",
"routeGeneration += 1",
"resetTicketView(incomingTicket)",
]
) {
assertStringIncludes(effectSource, token);
}
});
Deno.test("ticket detail fences stale mutation responses", () => {
for (
const operation of [
"async function mutate(",
"async function queueTicket(",
"async function mutateAssignment(",
]
) {
const operationStart = pageSource.indexOf(operation);
assert(operationStart >= 0, `missing ${operation}`);
const nextOperation = pageSource.indexOf(
"\n async function ",
operationStart + 1,
);
const operationSource = pageSource.slice(
operationStart,
nextOperation === -1 ? undefined : nextOperation,
);
assertStringIncludes(operationSource, "const generation = routeGeneration");
assertStringIncludes(operationSource, "generation !== routeGeneration");
assertStringIncludes(operationSource, "generation === routeGeneration");
}
});