From acb3c6d68bc9c01e81eafcfcae82e145e1ceffc1 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 29 Aug 2026 22:52:03 +0900 Subject: [PATCH 1/2] fix: separate Ticket keys from assignment identity --- crates/ticket/src/tool.rs | 2 +- .../worker/src/feature/builtin/objective.rs | 12 +- .../feature/builtin/resource_projection.rs | 42 +++-- crates/worker/src/feature/builtin/ticket.rs | 143 +++++++++++++++--- crates/workspace-server/src/server.rs | 54 +++++++ .../workspace-schema-migrations.md | 2 +- 6 files changed, 200 insertions(+), 55 deletions(-) diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index a38615b2..62b1380d 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -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), } diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index 2c91cefd..44dbceb0 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -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 { 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(), diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index 0eb32758..a169f58b 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -228,7 +228,7 @@ pub(super) fn project_ticket_query(value: Value) -> Result Result { 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, _>>()?, 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, _>>()?; 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, _>>()?; 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 Result { 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 Result { 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 { 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 { fn project_objective_summary(value: &Value) -> Result { 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 Result { 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, key: &str) -> Result, S .collect() } -fn human_ref(object: &Map, key: &str, prefix: &str) -> Result { +fn resource_ref(object: &Map, key: &str, prefix: &str) -> Result { 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 { +fn validate_resource_ref(value: String, prefix: &str) -> Result { 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()); } } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 0194d9d0..65ad9749 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -306,19 +306,32 @@ struct BackendTicketService { backend: TicketToolBackend, } +struct WorkspaceTicketService { + backend: WorkspaceHttpTicketBackend, +} + +fn ticket_handoff_from_record(ticket: Ticket) -> Result { + 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 { - 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 { + 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 = Arc::new(BackendTicketService { - backend: backend.clone(), - }); + let ticket_service: Arc = 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 { + 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, ) -> TicketResult { + 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( + client: Arc, + method: WorkspaceRequestMethod, + endpoint: String, + body: Option, + ) -> TicketResult { + 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, + method: WorkspaceRequestMethod, + endpoint: String, + body: Option, + ) -> TicketResult { 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")); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cc374820..bb0263d7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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 { + 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, +) -> 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, @@ -8217,6 +8247,11 @@ async fn spawn_known_worker( ) -> ApiResult> { 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 { @@ -20220,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(), diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index 9d892f44..d770755c 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -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; From 402ae0d4669d126d484020f61ec8aee3633c8361 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 29 Aug 2026 23:10:32 +0900 Subject: [PATCH 2/2] fix: replay annotated history in Web Console --- .../src/lib/workspace/console/model.test.ts | 102 ++++++++++++++++++ .../src/lib/workspace/console/model.ts | 72 +++++++++++-- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 39732416..92c2688f 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -165,6 +165,108 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi ); }); +Deno.test("reload snapshot projects provenance-annotated history entries", () => { + const metadata = { + entry_id: "history-entry-1", + origin: { + kind: "model_output", + worker: { + workspace_id: "workspace-secret", + runtime_id: "runtime-secret", + worker_id: "worker-secret", + }, + }, + }; + const annotated = (item: unknown) => ({ item, metadata }); + const projection = projectConsole([{ + eventId: "annotated-reload", + event: snapshotEvent("/repo", [ + { + kind: "annotated_segment_start", + ts: 1, + session_id: "session-1", + system_prompt: null, + config: {}, + history: [annotated({ + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "older committed reply" }], + })], + }, + { + kind: "annotated_user_input", + ts: 2, + segments: [{ kind: "text", content: "latest user message" }], + history: [annotated({ + kind: "message", + role: "user", + content: [{ kind: "text", text: "latest user message" }], + })], + }, + { + kind: "annotated_assistant_item", + ts: 3, + entry: annotated({ + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "latest committed reply" }], + }), + }, + { + kind: "annotated_assistant_item", + ts: 4, + entry: annotated({ + kind: "tool_call", + call_id: "annotated-call", + name: "Read", + arguments: '{"file_path":"/repo/a.md"}', + }), + }, + { + kind: "annotated_tool_result", + ts: 5, + entry: annotated({ + kind: "tool_result", + call_id: "annotated-call", + summary: "Read 1 line from /repo/a.md", + content: "1→content", + is_error: false, + }), + }, + { + kind: "annotated_system_item", + ts: 6, + entry: annotated({ + kind: "notification", + message: "Worker completed", + }), + }, + ]), + }]); + + assertEquals( + projection.lines.map((line) => + `${line.kind}:${line.toolCallLabel ?? line.body}` + ), + [ + "assistant:older committed reply", + "user:latest user message", + "assistant:latest committed reply", + "tool:Read(1 file)", + "system:Worker completed", + ], + ); + const visible = JSON.stringify(projection.lines); + assert( + !visible.includes("workspace-secret"), + "history metadata must not enter Console rows", + ); + assert( + !visible.includes("runtime-secret"), + "history origin must remain non-visible metadata", + ); +}); + Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { assert( workerConsoleHref({ diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index d9e56953..86d4ce8b 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -1960,24 +1960,34 @@ function applyLogEntry( applyLoggedItem(projection, `${eventId}-history-${index}`, item) ); break; - case "user_input": - projection.lines.push( - line( - eventId, - "user", - "User", - segmentsToText(arrayField(entry, "segments") as Segment[]), - ), + case "annotated_segment_start": + arrayField(entry, "history").forEach((historyEntry, index) => + applyLoggedHistoryEntry( + projection, + `${eventId}-history-${index}`, + historyEntry, + ) ); break; + case "user_input": + case "annotated_user_input": + applyLoggedUserInput(projection, eventId, entry); + break; case "system_item": projection.lines.push(systemItemLine(eventId, entry["item"])); applyTaskSystemItem(projection, entry["item"]); break; + case "annotated_system_item": + applyLoggedSystemEntry(projection, eventId, entry["entry"]); + break; case "assistant_item": case "tool_result": applyLoggedItem(projection, eventId, entry["item"]); break; + case "annotated_assistant_item": + case "annotated_tool_result": + applyLoggedHistoryEntry(projection, eventId, entry["entry"]); + break; case "run_errored": projection.lines.push( line( @@ -2056,6 +2066,52 @@ function compactMessageForState( } } +function applyLoggedUserInput( + projection: ConsoleProjection, + eventId: string, + entry: Record, +): void { + let body = segmentsToText(arrayField(entry, "segments") as Segment[]); + if (!body && stringField(entry, "kind") === "annotated_user_input") { + body = loggedUserText(arrayField(entry, "history")); + } + projection.lines.push(line(eventId, "user", "User", body)); +} + +function loggedUserText(history: unknown[]): string { + for (const historyEntry of history) { + if (!isRecord(historyEntry) || !isRecord(historyEntry["item"])) continue; + const item = historyEntry["item"]; + if ( + stringField(item, "kind") !== "message" || + stringField(item, "role") !== "user" + ) { + continue; + } + return loggedContentText(arrayField(item, "content")); + } + return ""; +} + +function applyLoggedHistoryEntry( + projection: ConsoleProjection, + eventId: string, + historyEntry: unknown, +): void { + if (!isRecord(historyEntry)) return; + applyLoggedItem(projection, eventId, historyEntry["item"]); +} + +function applyLoggedSystemEntry( + projection: ConsoleProjection, + eventId: string, + historyEntry: unknown, +): void { + if (!isRecord(historyEntry)) return; + projection.lines.push(systemItemLine(eventId, historyEntry["item"])); + applyTaskSystemItem(projection, historyEntry["item"]); +} + function applyLoggedItem( projection: ConsoleProjection, eventId: string,