fix: align Companion REST DTO contracts
This commit is contained in:
@@ -38,5 +38,10 @@ pub use target::{
|
|||||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||||
};
|
};
|
||||||
pub use worker_client::WorkerClient;
|
pub use worker_client::WorkerClient;
|
||||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
pub use workspace_api::{
|
||||||
|
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
|
||||||
|
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
|
||||||
|
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
|
||||||
|
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
|
||||||
|
};
|
||||||
pub use workspace_product::BackendWorkspaceProductClient;
|
pub use workspace_product::BackendWorkspaceProductClient;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::companion_api_typescript());
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
|||||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DiagnosticSeverity {
|
pub enum DiagnosticSeverity {
|
||||||
Info,
|
Info,
|
||||||
@@ -101,6 +102,7 @@ pub enum DiagnosticSeverity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct Diagnostic {
|
pub struct Diagnostic {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub severity: DiagnosticSeverity,
|
pub severity: DiagnosticSeverity,
|
||||||
@@ -325,6 +327,7 @@ pub struct WorkerCapabilitySummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum WorkspaceWorkerSubject {
|
pub enum WorkspaceWorkerSubject {
|
||||||
RuntimeWorker {
|
RuntimeWorker {
|
||||||
@@ -337,6 +340,7 @@ pub enum WorkspaceWorkerSubject {
|
|||||||
/// Runtime placement appears only in the typed subject required by Worker
|
/// Runtime placement appears only in the typed subject required by Worker
|
||||||
/// control operations; provider and launch internals are intentionally omitted.
|
/// control operations; provider and launch internals are intentionally omitted.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkspaceWorkerDiscoveryItem {
|
pub struct WorkspaceWorkerDiscoveryItem {
|
||||||
pub subject: WorkspaceWorkerSubject,
|
pub subject: WorkspaceWorkerSubject,
|
||||||
pub resource_key: String,
|
pub resource_key: String,
|
||||||
@@ -346,6 +350,137 @@ pub struct WorkspaceWorkerDiscoveryItem {
|
|||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public lifecycle projection for the Workspace Companion endpoint.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanionLifecycleState {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public outcome of a Companion message submission.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanionMessageDisposition {
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public, bounded transport metadata for Companion status.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionTransportSummary {
|
||||||
|
pub mode: String,
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion status response.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionStatusResponse {
|
||||||
|
pub state: CompanionLifecycleState,
|
||||||
|
pub worker: Option<WorkspaceWorkerDiscoveryItem>,
|
||||||
|
pub transport: CompanionTransportSummary,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion message request.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionMessageRequest {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion cancellation request.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionCancelRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion message response.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionMessageResponse {
|
||||||
|
pub state: CompanionMessageDisposition,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-visible role accepted in the public Companion transcript.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanionTranscriptRole {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One allowlisted, user-visible Companion transcript item.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionTranscriptItem {
|
||||||
|
pub sequence: usize,
|
||||||
|
pub role: CompanionTranscriptRole,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded public Companion transcript projection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionTranscriptProjection {
|
||||||
|
pub state: CompanionLifecycleState,
|
||||||
|
pub start: usize,
|
||||||
|
pub limit: usize,
|
||||||
|
pub total: usize,
|
||||||
|
pub next: Option<usize>,
|
||||||
|
pub items: Vec<CompanionTranscriptItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn companion_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
DiagnosticSeverity::decl(&config),
|
||||||
|
Diagnostic::decl(&config),
|
||||||
|
WorkspaceWorkerSubject::decl(&config),
|
||||||
|
WorkspaceWorkerDiscoveryItem::decl(&config),
|
||||||
|
CompanionLifecycleState::decl(&config),
|
||||||
|
CompanionMessageDisposition::decl(&config),
|
||||||
|
CompanionTransportSummary::decl(&config),
|
||||||
|
CompanionStatusResponse::decl(&config),
|
||||||
|
CompanionMessageRequest::decl(&config),
|
||||||
|
CompanionCancelRequest::decl(&config),
|
||||||
|
CompanionMessageResponse::decl(&config),
|
||||||
|
CompanionTranscriptRole::decl(&config),
|
||||||
|
CompanionTranscriptItem::decl(&config),
|
||||||
|
CompanionTranscriptProjection::decl(&config),
|
||||||
|
];
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkspaceWorkerDiscoveryPage {
|
pub struct WorkspaceWorkerDiscoveryPage {
|
||||||
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
|
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
|
||||||
@@ -560,4 +695,159 @@ mod tests {
|
|||||||
|
|
||||||
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn round_trip<T>(value: T)
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let encoded = serde_json::to_vec(&value).expect("fixture should serialize");
|
||||||
|
let decoded: T = serde_json::from_slice(&encoded).expect("fixture should deserialize");
|
||||||
|
assert_eq!(decoded, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||||
|
WorkspaceWorkerDiscoveryItem {
|
||||||
|
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||||
|
runtime_id: "arcadia".to_string(),
|
||||||
|
worker_id: "worker-7".to_string(),
|
||||||
|
},
|
||||||
|
resource_key: "W-7".to_string(),
|
||||||
|
display_name: "Companion".to_string(),
|
||||||
|
profile: Some("builtin:companion".to_string()),
|
||||||
|
status: Some("idle".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_status_fixtures_round_trip() {
|
||||||
|
for state in [
|
||||||
|
CompanionLifecycleState::Idle,
|
||||||
|
CompanionLifecycleState::Running,
|
||||||
|
CompanionLifecycleState::Stopped,
|
||||||
|
] {
|
||||||
|
round_trip(CompanionStatusResponse {
|
||||||
|
state,
|
||||||
|
worker: Some(companion_worker()),
|
||||||
|
transport: CompanionTransportSummary {
|
||||||
|
mode: "worker_runtime".to_string(),
|
||||||
|
available: state != CompanionLifecycleState::Stopped,
|
||||||
|
},
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_message_fixtures_round_trip() {
|
||||||
|
for state in [
|
||||||
|
CompanionMessageDisposition::Accepted,
|
||||||
|
CompanionMessageDisposition::Rejected,
|
||||||
|
] {
|
||||||
|
round_trip(CompanionMessageResponse {
|
||||||
|
state,
|
||||||
|
message: if state == CompanionMessageDisposition::Accepted {
|
||||||
|
"accepted"
|
||||||
|
} else {
|
||||||
|
"rejected"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_transcript_fixture_round_trips() {
|
||||||
|
round_trip(CompanionTranscriptProjection {
|
||||||
|
state: CompanionLifecycleState::Idle,
|
||||||
|
start: 0,
|
||||||
|
limit: 2,
|
||||||
|
total: 2,
|
||||||
|
next: None,
|
||||||
|
items: vec![
|
||||||
|
CompanionTranscriptItem {
|
||||||
|
sequence: 1,
|
||||||
|
role: CompanionTranscriptRole::User,
|
||||||
|
content: "hello".to_string(),
|
||||||
|
created_at: "2026-08-31T00:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
CompanionTranscriptItem {
|
||||||
|
sequence: 2,
|
||||||
|
role: CompanionTranscriptRole::Assistant,
|
||||||
|
content: "hi".to_string(),
|
||||||
|
created_at: "2026-08-31T00:00:01Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_transcript_rejects_system_and_private_fields() {
|
||||||
|
let public_item = CompanionTranscriptItem {
|
||||||
|
sequence: 1,
|
||||||
|
role: CompanionTranscriptRole::Assistant,
|
||||||
|
content: "visible".to_string(),
|
||||||
|
created_at: "2026-08-31T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
let public_fields = serde_json::to_value(public_item)
|
||||||
|
.expect("public transcript item should serialize")
|
||||||
|
.as_object()
|
||||||
|
.expect("public transcript item should be an object")
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
public_fields,
|
||||||
|
["content", "created_at", "role", "sequence"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
);
|
||||||
|
|
||||||
|
let system_item = serde_json::json!({
|
||||||
|
"sequence": 1,
|
||||||
|
"role": "system",
|
||||||
|
"content": "raw system prompt",
|
||||||
|
"created_at": "2026-08-31T00:00:00Z"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<CompanionTranscriptItem>(system_item).is_err());
|
||||||
|
|
||||||
|
let private_item = serde_json::json!({
|
||||||
|
"sequence": 1,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "visible",
|
||||||
|
"created_at": "2026-08-31T00:00:00Z",
|
||||||
|
"reasoning": "hidden",
|
||||||
|
"credential": "secret",
|
||||||
|
"provider_session_id": "session-private"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
#[test]
|
||||||
|
fn generated_companion_api_contract_is_current() {
|
||||||
|
let expected = companion_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/companion-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize_typescript(&actual),
|
||||||
|
normalize_typescript(&expected),
|
||||||
|
"regenerate Companion API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
fn normalize_typescript(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.replace(";}", "}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,14 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use workspace_api::{
|
||||||
|
CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
|
pub use workspace_api::{
|
||||||
|
CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
#[serde(rename_all = "snake_case")]
|
};
|
||||||
pub enum CompanionState {
|
|
||||||
Disabled,
|
|
||||||
Rejected,
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionStatusResponse {
|
|
||||||
pub state: CompanionState,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub worker: Option<WorkerSummary>,
|
|
||||||
pub transport: CompanionTransportSummary,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionTransportSummary {
|
|
||||||
pub kind: String,
|
|
||||||
pub completion: String,
|
|
||||||
pub limitation: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionMessageRequest {
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
|
|
||||||
pub struct CompanionCancelRequest {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionMessageResponse {
|
|
||||||
pub state: CompanionState,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub worker: Option<WorkerSummary>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub user_item: Option<CompanionTranscriptItem>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub assistant_item: Option<CompanionTranscriptItem>,
|
|
||||||
pub transcript: CompanionTranscriptProjection,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionTranscriptProjection {
|
|
||||||
pub state: CompanionState,
|
|
||||||
pub start: usize,
|
|
||||||
pub limit: usize,
|
|
||||||
pub total_items: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub next_start: Option<usize>,
|
|
||||||
pub items: Vec<CompanionTranscriptItem>,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionTranscriptItem {
|
|
||||||
pub sequence: u64,
|
|
||||||
pub role: String,
|
|
||||||
pub content: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub source: String,
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
pub struct CompanionConsole;
|
pub struct CompanionConsole;
|
||||||
|
|
||||||
impl CompanionConsole {
|
impl CompanionConsole {
|
||||||
@@ -81,68 +18,50 @@ impl CompanionConsole {
|
|||||||
|
|
||||||
pub fn status(&self) -> CompanionStatusResponse {
|
pub fn status(&self) -> CompanionStatusResponse {
|
||||||
CompanionStatusResponse {
|
CompanionStatusResponse {
|
||||||
state: CompanionState::Disabled,
|
state: CompanionLifecycleState::Stopped,
|
||||||
worker: None,
|
worker: None,
|
||||||
transport: disabled_transport(),
|
transport: CompanionTransportSummary {
|
||||||
|
mode: "disabled".to_string(),
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
diagnostics: vec![disabled_diagnostic()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
||||||
CompanionTranscriptProjection {
|
CompanionTranscriptProjection {
|
||||||
state: CompanionState::Disabled,
|
state: CompanionLifecycleState::Stopped,
|
||||||
start,
|
start,
|
||||||
limit,
|
limit,
|
||||||
total_items: 0,
|
total: 0,
|
||||||
next_start: None,
|
next: None,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||||
disabled_message_response(CompanionState::Rejected)
|
disabled_message_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
||||||
disabled_message_response(CompanionState::Cancelled)
|
disabled_message_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse {
|
fn disabled_message_response() -> CompanionMessageResponse {
|
||||||
CompanionMessageResponse {
|
CompanionMessageResponse {
|
||||||
state,
|
state: CompanionMessageDisposition::Rejected,
|
||||||
worker: None,
|
message: "Workspace Companion auto-start is disabled; create or select an explicit Worker instead."
|
||||||
user_item: None,
|
|
||||||
assistant_item: None,
|
|
||||||
transcript: CompanionTranscriptProjection {
|
|
||||||
state: CompanionState::Disabled,
|
|
||||||
start: 0,
|
|
||||||
limit: 200,
|
|
||||||
total_items: 0,
|
|
||||||
next_start: None,
|
|
||||||
items: Vec::new(),
|
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
|
||||||
},
|
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disabled_transport() -> CompanionTransportSummary {
|
|
||||||
CompanionTransportSummary {
|
|
||||||
kind: "none".to_string(),
|
|
||||||
completion: "disabled".to_string(),
|
|
||||||
limitation:
|
|
||||||
"Workspace Companion auto-start has been removed; create an explicit Worker instead."
|
|
||||||
.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disabled_diagnostic() -> RuntimeDiagnostic {
|
|
||||||
RuntimeDiagnostic {
|
|
||||||
code: "companion_disabled".to_string(),
|
|
||||||
severity: DiagnosticSeverity::Info,
|
|
||||||
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn disabled_diagnostic() -> Diagnostic {
|
||||||
|
Diagnostic {
|
||||||
|
code: "companion_disabled".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Info,
|
||||||
|
message:
|
||||||
|
"Workspace Companion auto-start was removed; use the explicit Worker lifecycle instead."
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11084,33 +11084,43 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn companion_console_extension_point(status: &CompanionStatusResponse) -> ExtensionPointState {
|
fn companion_console_extension_point(status: &CompanionStatusResponse) -> ExtensionPointState {
|
||||||
let completion = status.transport.completion.clone();
|
let extension_status = match status.state {
|
||||||
let note = match completion.as_str() {
|
workspace_api::CompanionLifecycleState::Idle => "idle",
|
||||||
"connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(),
|
workspace_api::CompanionLifecycleState::Running => "running",
|
||||||
"not_input_capable" => {
|
workspace_api::CompanionLifecycleState::Stopped => "stopped",
|
||||||
let diagnostic_codes = status
|
}
|
||||||
.diagnostics
|
.to_string();
|
||||||
.iter()
|
let diagnostic_codes = status
|
||||||
.map(|diagnostic| diagnostic.code.as_str())
|
.diagnostics
|
||||||
.collect::<Vec<_>>()
|
.iter()
|
||||||
.join(", ");
|
.map(|diagnostic| diagnostic.code.as_str())
|
||||||
if diagnostic_codes.is_empty() {
|
.collect::<Vec<_>>()
|
||||||
"Workspace Companion is not input-capable; check provider, config, profile, secret, and authority diagnostics.".to_string()
|
.join(", ");
|
||||||
} else {
|
let note = if status.transport.available {
|
||||||
format!(
|
"Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path."
|
||||||
"Workspace Companion is not input-capable; check typed diagnostics: {diagnostic_codes}."
|
.to_string()
|
||||||
)
|
} else if diagnostic_codes.is_empty() {
|
||||||
}
|
"Workspace Companion is unavailable; create or select an explicit Worker instead."
|
||||||
}
|
.to_string()
|
||||||
"disabled" => "Workspace Companion auto-start has been removed; create an explicit Worker instead.".to_string(),
|
} else {
|
||||||
other => format!(
|
format!("Workspace Companion is unavailable; check typed diagnostics: {diagnostic_codes}.")
|
||||||
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state."
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
ExtensionPointState {
|
ExtensionPointState {
|
||||||
status: completion,
|
status: extension_status,
|
||||||
note,
|
note,
|
||||||
diagnostics: status.diagnostics.clone(),
|
diagnostics: status
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| RuntimeDiagnostic {
|
||||||
|
code: diagnostic.code.clone(),
|
||||||
|
severity: match diagnostic.severity {
|
||||||
|
workspace_api::DiagnosticSeverity::Info => DiagnosticSeverity::Info,
|
||||||
|
workspace_api::DiagnosticSeverity::Warning => DiagnosticSeverity::Warning,
|
||||||
|
workspace_api::DiagnosticSeverity::Error => DiagnosticSeverity::Error,
|
||||||
|
},
|
||||||
|
message: diagnostic.message.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24137,10 +24147,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
||||||
assert_eq!(companion_status["state"], "disabled");
|
assert_eq!(companion_status["state"], "stopped");
|
||||||
assert!(companion_status["worker"].is_null());
|
assert!(companion_status["worker"].is_null());
|
||||||
assert_eq!(companion_status["transport"]["kind"], "none");
|
assert_eq!(companion_status["transport"]["mode"], "disabled");
|
||||||
assert_eq!(companion_status["transport"]["completion"], "disabled");
|
assert_eq!(companion_status["transport"]["available"], false);
|
||||||
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
||||||
|
|
||||||
let companion_message = post_json(
|
let companion_message = post_json(
|
||||||
@@ -24150,16 +24160,14 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(companion_message["state"], "rejected");
|
assert_eq!(companion_message["state"], "rejected");
|
||||||
assert_eq!(
|
assert!(companion_message.get("accepted").is_none());
|
||||||
companion_message["diagnostics"][0]["code"],
|
assert!(companion_message.get("diagnostics").is_none());
|
||||||
"companion_disabled"
|
assert!(companion_message.get("user_item").is_none());
|
||||||
);
|
assert!(companion_message.get("assistant_item").is_none());
|
||||||
assert!(companion_message["user_item"].is_null());
|
|
||||||
assert!(companion_message["assistant_item"].is_null());
|
|
||||||
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
||||||
|
|
||||||
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||||
assert_eq!(companion_transcript["total_items"], 0);
|
assert_eq!(companion_transcript["total"], 0);
|
||||||
|
|
||||||
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -24264,7 +24272,7 @@ mod tests {
|
|||||||
|
|
||||||
let workspace = get_json(app.clone(), "/api/workspace").await;
|
let workspace = get_json(app.clone(), "/api/workspace").await;
|
||||||
let workspace_companion = &workspace["extension_points"]["companion_console"];
|
let workspace_companion = &workspace["extension_points"]["companion_console"];
|
||||||
assert_eq!(workspace_companion["status"], "disabled");
|
assert_eq!(workspace_companion["status"], "stopped");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace_companion["diagnostics"][0]["code"],
|
workspace_companion["diagnostics"][0]["code"],
|
||||||
"companion_disabled"
|
"companion_disabled"
|
||||||
@@ -24273,12 +24281,13 @@ mod tests {
|
|||||||
workspace_companion["note"]
|
workspace_companion["note"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("auto-start has been removed")
|
.contains("typed diagnostics")
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = get_json(app.clone(), "/api/companion/status").await;
|
let status = get_json(app.clone(), "/api/companion/status").await;
|
||||||
assert_eq!(status["state"], "disabled");
|
assert_eq!(status["state"], "stopped");
|
||||||
assert_eq!(status["transport"]["completion"], "disabled");
|
assert_eq!(status["transport"]["mode"], "disabled");
|
||||||
|
assert_eq!(status["transport"]["available"], false);
|
||||||
assert!(status["worker"].is_null());
|
assert!(status["worker"].is_null());
|
||||||
|
|
||||||
let response = post_json(
|
let response = post_json(
|
||||||
@@ -24288,13 +24297,14 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response["state"], "rejected");
|
assert_eq!(response["state"], "rejected");
|
||||||
assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
|
assert!(response.get("accepted").is_none());
|
||||||
assert!(response["user_item"].is_null());
|
assert!(response.get("diagnostics").is_none());
|
||||||
assert!(response["assistant_item"].is_null());
|
assert!(response.get("user_item").is_none());
|
||||||
|
assert!(response.get("assistant_item").is_none());
|
||||||
|
|
||||||
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||||
assert_eq!(transcript["state"], "disabled");
|
assert_eq!(transcript["state"], "stopped");
|
||||||
assert_eq!(transcript["total_items"], 0);
|
assert_eq!(transcript["total"], 0);
|
||||||
|
|
||||||
let workers = get_json(app, "/api/workers").await;
|
let workers = get_json(app, "/api/workers").await;
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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 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/override-stack.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/companion/api.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/override-stack.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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.
|
||||||
|
// Do not edit manually.
|
||||||
|
|
||||||
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export type Diagnostic = {
|
||||||
|
code: string;
|
||||||
|
severity: DiagnosticSeverity;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceWorkerSubject = {
|
||||||
|
"kind": "runtime_worker";
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceWorkerDiscoveryItem = {
|
||||||
|
subject: WorkspaceWorkerSubject;
|
||||||
|
resource_key: string;
|
||||||
|
display_name: string;
|
||||||
|
profile: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionLifecycleState = "idle" | "running" | "stopped";
|
||||||
|
|
||||||
|
export type CompanionMessageDisposition = "accepted" | "rejected";
|
||||||
|
|
||||||
|
export type CompanionTransportSummary = { mode: string; available: boolean };
|
||||||
|
|
||||||
|
export type CompanionStatusResponse = {
|
||||||
|
state: CompanionLifecycleState;
|
||||||
|
worker: WorkspaceWorkerDiscoveryItem | null;
|
||||||
|
transport: CompanionTransportSummary;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionMessageRequest = { content: string };
|
||||||
|
|
||||||
|
export type CompanionCancelRequest = { reason?: string | null };
|
||||||
|
|
||||||
|
export type CompanionMessageResponse = {
|
||||||
|
state: CompanionMessageDisposition;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionTranscriptRole = "user" | "assistant";
|
||||||
|
|
||||||
|
export type CompanionTranscriptItem = {
|
||||||
|
sequence: number;
|
||||||
|
role: CompanionTranscriptRole;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionTranscriptProjection = {
|
||||||
|
state: CompanionLifecycleState;
|
||||||
|
start: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
next: number | null;
|
||||||
|
items: Array<CompanionTranscriptItem>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
parseCompanionMessageResponse,
|
||||||
|
parseCompanionStatusResponse,
|
||||||
|
parseCompanionTranscriptProjection,
|
||||||
|
} from "./api.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(fn: () => unknown, message: string): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = {
|
||||||
|
subject: {
|
||||||
|
kind: "runtime_worker",
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
worker_id: "worker-7",
|
||||||
|
},
|
||||||
|
resource_key: "W-7",
|
||||||
|
display_name: "Companion",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
status: "idle",
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Companion status boundary accepts every public lifecycle state", () => {
|
||||||
|
for (const state of ["idle", "running", "stopped"] as const) {
|
||||||
|
const parsed = parseCompanionStatusResponse({
|
||||||
|
state,
|
||||||
|
worker,
|
||||||
|
transport: {
|
||||||
|
mode: "worker_runtime",
|
||||||
|
available: state !== "stopped",
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
assertEquals(parsed.state, state);
|
||||||
|
assertEquals(parsed.worker?.subject, worker.subject);
|
||||||
|
assertEquals(parsed.worker?.resource_key, "W-7");
|
||||||
|
assertEquals(parsed.worker?.display_name, "Companion");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion message boundary accepts accepted and rejected fixtures", () => {
|
||||||
|
assertEquals(
|
||||||
|
parseCompanionMessageResponse({
|
||||||
|
state: "accepted",
|
||||||
|
message: "accepted",
|
||||||
|
}),
|
||||||
|
{ state: "accepted", message: "accepted" },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
parseCompanionMessageResponse({
|
||||||
|
state: "rejected",
|
||||||
|
message: "rejected",
|
||||||
|
}),
|
||||||
|
{ state: "rejected", message: "rejected" },
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionMessageResponse({
|
||||||
|
state: "accepted",
|
||||||
|
message: "accepted",
|
||||||
|
provider_request_id: "private-request",
|
||||||
|
}),
|
||||||
|
"private message response fields should be rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion transcript boundary accepts only bounded user-visible items", () => {
|
||||||
|
const fixture = {
|
||||||
|
state: "idle" as const,
|
||||||
|
start: 0,
|
||||||
|
limit: 2,
|
||||||
|
total: 2,
|
||||||
|
next: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
sequence: 1,
|
||||||
|
role: "user" as const,
|
||||||
|
content: "hello",
|
||||||
|
created_at: "2026-08-31T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequence: 2,
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "hi",
|
||||||
|
created_at: "2026-08-31T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assertEquals(parseCompanionTranscriptProjection(fixture), fixture);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
...fixture,
|
||||||
|
items: [...fixture.items, fixture.items[0]],
|
||||||
|
}),
|
||||||
|
"items beyond the declared limit should be rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion transcript boundary rejects system and private fields", () => {
|
||||||
|
const base = {
|
||||||
|
state: "idle",
|
||||||
|
start: 0,
|
||||||
|
limit: 1,
|
||||||
|
total: 1,
|
||||||
|
next: null,
|
||||||
|
};
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
...base,
|
||||||
|
items: [{
|
||||||
|
sequence: 1,
|
||||||
|
role: "system",
|
||||||
|
content: "raw system prompt",
|
||||||
|
created_at: "2026-08-31T00:00:00Z",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
"system transcript content should be rejected",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
...base,
|
||||||
|
items: [{
|
||||||
|
sequence: 1,
|
||||||
|
role: "assistant",
|
||||||
|
content: "visible",
|
||||||
|
created_at: "2026-08-31T00:00:00Z",
|
||||||
|
reasoning: "hidden",
|
||||||
|
credential: "secret",
|
||||||
|
provider_session_id: "private-session",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
"private transcript fields should be rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion status boundary does not use display_name as Worker identity", () => {
|
||||||
|
const fixture = {
|
||||||
|
state: "idle",
|
||||||
|
worker: { ...worker, display_name: "W-999" },
|
||||||
|
transport: { mode: "worker_runtime", available: true },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
const parsed = parseCompanionStatusResponse(fixture);
|
||||||
|
assertEquals(parsed.worker?.subject, worker.subject);
|
||||||
|
assertEquals(parsed.worker?.resource_key, "W-7");
|
||||||
|
assertEquals(parsed.worker?.display_name, "W-999");
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionStatusResponse({
|
||||||
|
...fixture,
|
||||||
|
worker: { ...worker, resource_key: "Companion" },
|
||||||
|
}),
|
||||||
|
"display names must not substitute for canonical Worker resource keys",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type {
|
||||||
|
CompanionLifecycleState,
|
||||||
|
CompanionMessageDisposition,
|
||||||
|
CompanionMessageResponse,
|
||||||
|
CompanionStatusResponse,
|
||||||
|
CompanionTranscriptItem,
|
||||||
|
CompanionTranscriptProjection,
|
||||||
|
Diagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
WorkspaceWorkerDiscoveryItem,
|
||||||
|
WorkspaceWorkerSubject,
|
||||||
|
} from "$lib/generated/companion-api";
|
||||||
|
|
||||||
|
const MAX_TRANSCRIPT_ITEMS = 200;
|
||||||
|
const MAX_DIAGNOSTICS = 100;
|
||||||
|
const MAX_CONTENT_LENGTH = 64 * 1024;
|
||||||
|
|
||||||
|
export function parseCompanionStatusResponse(
|
||||||
|
value: unknown,
|
||||||
|
): CompanionStatusResponse {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"state",
|
||||||
|
"worker",
|
||||||
|
"transport",
|
||||||
|
"diagnostics",
|
||||||
|
]);
|
||||||
|
const transport = strictRecord(record.transport, ["mode", "available"]);
|
||||||
|
const diagnostics = boundedArray(record.diagnostics, MAX_DIAGNOSTICS).map(
|
||||||
|
parseDiagnostic,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: lifecycleState(record.state),
|
||||||
|
worker: record.worker === null ? null : parseWorker(record.worker),
|
||||||
|
transport: {
|
||||||
|
mode: boundedString(transport.mode, 100),
|
||||||
|
available: booleanValue(transport.available),
|
||||||
|
},
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCompanionMessageResponse(
|
||||||
|
value: unknown,
|
||||||
|
): CompanionMessageResponse {
|
||||||
|
const record = strictRecord(value, ["state", "message"]);
|
||||||
|
return {
|
||||||
|
state: messageDisposition(record.state),
|
||||||
|
message: boundedString(record.message, 8 * 1024),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCompanionTranscriptProjection(
|
||||||
|
value: unknown,
|
||||||
|
): CompanionTranscriptProjection {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"state",
|
||||||
|
"start",
|
||||||
|
"limit",
|
||||||
|
"total",
|
||||||
|
"next",
|
||||||
|
"items",
|
||||||
|
]);
|
||||||
|
const start = boundedInteger(record.start);
|
||||||
|
const limit = boundedInteger(record.limit);
|
||||||
|
if (limit < 1 || limit > MAX_TRANSCRIPT_ITEMS) {
|
||||||
|
throw new TypeError("Companion transcript limit is out of range");
|
||||||
|
}
|
||||||
|
const items = boundedArray(record.items, limit).map(parseTranscriptItem);
|
||||||
|
const total = boundedInteger(record.total);
|
||||||
|
if (total < items.length) {
|
||||||
|
throw new TypeError("Companion transcript total is smaller than its items");
|
||||||
|
}
|
||||||
|
const next = record.next === null ? null : boundedInteger(record.next);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: lifecycleState(record.state),
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
next,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTranscriptItem(value: unknown): CompanionTranscriptItem {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"sequence",
|
||||||
|
"role",
|
||||||
|
"content",
|
||||||
|
"created_at",
|
||||||
|
]);
|
||||||
|
const role = record.role;
|
||||||
|
if (role !== "user" && role !== "assistant") {
|
||||||
|
throw new TypeError("Companion transcript role is not user-visible");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sequence: boundedInteger(record.sequence),
|
||||||
|
role,
|
||||||
|
content: boundedString(record.content, MAX_CONTENT_LENGTH),
|
||||||
|
created_at: boundedString(record.created_at, 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorker(value: unknown): WorkspaceWorkerDiscoveryItem {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"subject",
|
||||||
|
"resource_key",
|
||||||
|
"display_name",
|
||||||
|
"profile",
|
||||||
|
"status",
|
||||||
|
], ["status"]);
|
||||||
|
const subject = parseWorkerSubject(record.subject);
|
||||||
|
const resourceKey = boundedString(record.resource_key, 100);
|
||||||
|
if (!/^W-[1-9][0-9]*$/.test(resourceKey)) {
|
||||||
|
throw new TypeError("Companion worker resource_key is not canonical");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
resource_key: resourceKey,
|
||||||
|
display_name: boundedString(record.display_name, 256),
|
||||||
|
profile: nullableString(record.profile, 256),
|
||||||
|
...(record.status === undefined
|
||||||
|
? {}
|
||||||
|
: { status: nullableString(record.status, 100) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkerSubject(value: unknown): WorkspaceWorkerSubject {
|
||||||
|
const record = strictRecord(value, ["kind", "runtime_id", "worker_id"]);
|
||||||
|
if (record.kind !== "runtime_worker") {
|
||||||
|
throw new TypeError("Companion worker subject kind is invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "runtime_worker",
|
||||||
|
runtime_id: boundedString(record.runtime_id, 256),
|
||||||
|
worker_id: boundedString(record.worker_id, 256),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiagnostic(value: unknown): Diagnostic {
|
||||||
|
const record = strictRecord(value, ["code", "severity", "message"]);
|
||||||
|
return {
|
||||||
|
code: boundedString(record.code, 256),
|
||||||
|
severity: diagnosticSeverity(record.severity),
|
||||||
|
message: boundedString(record.message, 4 * 1024),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleState(value: unknown): CompanionLifecycleState {
|
||||||
|
if (value !== "idle" && value !== "running" && value !== "stopped") {
|
||||||
|
throw new TypeError("Companion lifecycle state is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageDisposition(value: unknown): CompanionMessageDisposition {
|
||||||
|
if (value !== "accepted" && value !== "rejected") {
|
||||||
|
throw new TypeError("Companion message disposition is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticSeverity(value: unknown): DiagnosticSeverity {
|
||||||
|
if (value !== "info" && value !== "warning" && value !== "error") {
|
||||||
|
throw new TypeError("Companion diagnostic severity is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly string[],
|
||||||
|
optionalKeys: readonly string[] = [],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new TypeError("Companion API value is not an object");
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const allowed = new Set(keys);
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (!allowed.has(key)) {
|
||||||
|
throw new TypeError(`Companion API field is not public: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const optional = new Set(optionalKeys);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!optional.has(key) && !(key in record)) {
|
||||||
|
throw new TypeError(`Companion API field is missing: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedArray(value: unknown, limit: number): unknown[] {
|
||||||
|
if (!Array.isArray(value) || value.length > limit) {
|
||||||
|
throw new TypeError("Companion API array is invalid or exceeds its limit");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value: unknown, limit: number): string {
|
||||||
|
if (typeof value !== "string" || value.length > limit) {
|
||||||
|
throw new TypeError("Companion API string is invalid or exceeds its limit");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown, limit: number): string | null {
|
||||||
|
return value === null ? null : boundedString(value, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown): boolean {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new TypeError("Companion API value is not a boolean");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedInteger(value: unknown): number {
|
||||||
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||||
|
throw new TypeError("Companion API value is not a non-negative integer");
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
@@ -448,56 +448,15 @@ export type ObjectiveListResponse = {
|
|||||||
record_authority: string;
|
record_authority: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanionState =
|
export type {
|
||||||
| "ready"
|
CompanionCancelRequest,
|
||||||
| "busy"
|
CompanionLifecycleState,
|
||||||
| "error"
|
CompanionMessageDisposition,
|
||||||
| "timeout"
|
CompanionMessageRequest,
|
||||||
| "cancelled"
|
CompanionMessageResponse,
|
||||||
| "accepted"
|
CompanionStatusResponse,
|
||||||
| "rejected";
|
CompanionTranscriptItem,
|
||||||
|
CompanionTranscriptProjection,
|
||||||
export type CompanionTransportSummary = {
|
CompanionTranscriptRole,
|
||||||
kind: string;
|
CompanionTransportSummary,
|
||||||
completion: string;
|
} from "$lib/generated/companion-api";
|
||||||
limitation: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionStatusResponse = {
|
|
||||||
state: CompanionState;
|
|
||||||
worker?: Worker | null;
|
|
||||||
transport: CompanionTransportSummary;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionTranscriptItem = {
|
|
||||||
sequence: number;
|
|
||||||
role: "user" | "assistant" | "system" | string;
|
|
||||||
content: string;
|
|
||||||
created_at: string;
|
|
||||||
source: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionTranscriptProjection = {
|
|
||||||
state: CompanionState;
|
|
||||||
start: number;
|
|
||||||
limit: number;
|
|
||||||
total_items: number;
|
|
||||||
next_start?: number | null;
|
|
||||||
items: CompanionTranscriptItem[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionMessageRequest = {
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionMessageResponse = {
|
|
||||||
state: CompanionState;
|
|
||||||
worker?: Worker | null;
|
|
||||||
user_item?: CompanionTranscriptItem | null;
|
|
||||||
assistant_item?: CompanionTranscriptItem | null;
|
|
||||||
transcript: CompanionTranscriptProjection;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user