ui: move workers list to page
This commit is contained in:
@@ -1,35 +1,13 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker_runtime::catalog::ProfileSelector;
|
||||
use worker_runtime::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
|
||||
use crate::hosts::{
|
||||
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
||||
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
|
||||
WorkerSummary, WorkerTranscriptItem as RuntimeTranscriptItem, WorkerTranscriptProjection,
|
||||
};
|
||||
|
||||
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
const COMPANION_PROFILE_ID: &str = "builtin:companion";
|
||||
const COMPANION_CONFIG_BUNDLE_ID: &str = "workspace-companion-config";
|
||||
const MAX_MESSAGE_CHARS: usize = 8_000;
|
||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompanionState {
|
||||
Ready,
|
||||
Busy,
|
||||
Error,
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Accepted,
|
||||
Disabled,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -94,698 +72,77 @@ pub struct CompanionTranscriptItem {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CompanionWorkerState {
|
||||
state: CompanionState,
|
||||
worker: Option<WorkerSummary>,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
pub struct CompanionConsole {
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
worker: Mutex<CompanionWorkerState>,
|
||||
}
|
||||
pub struct CompanionConsole;
|
||||
|
||||
impl CompanionConsole {
|
||||
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
|
||||
let initial = spawn_companion_worker(&runtime);
|
||||
Self {
|
||||
runtime,
|
||||
worker: Mutex::new(initial),
|
||||
}
|
||||
pub fn disabled() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn status(&self) -> CompanionStatusResponse {
|
||||
match self.refresh_worker_state() {
|
||||
Ok(worker) => CompanionStatusResponse {
|
||||
state: worker.state,
|
||||
worker: worker.worker.clone(),
|
||||
transport: companion_transport(worker.worker.as_ref()),
|
||||
diagnostics: worker.diagnostics.clone(),
|
||||
},
|
||||
Err(diagnostic) => CompanionStatusResponse {
|
||||
state: CompanionState::Error,
|
||||
worker: None,
|
||||
transport: companion_transport(None),
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
CompanionStatusResponse {
|
||||
state: CompanionState::Disabled,
|
||||
worker: None,
|
||||
transport: disabled_transport(),
|
||||
diagnostics: vec![disabled_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
||||
match self.current_worker() {
|
||||
Ok(Some(worker)) => {
|
||||
match self
|
||||
.runtime
|
||||
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, start, limit)
|
||||
{
|
||||
Ok(transcript) => project_runtime_transcript(
|
||||
&transcript,
|
||||
companion_state_for_worker(&worker),
|
||||
Vec::new(),
|
||||
),
|
||||
Err(error) => CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_transcript_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Companion Worker transcript is unavailable: {error:?}"),
|
||||
)],
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_worker_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Workspace Companion Worker is unavailable",
|
||||
)],
|
||||
},
|
||||
Err(diagnostic) => CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
CompanionTranscriptProjection {
|
||||
state: CompanionState::Disabled,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![disabled_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||
let content = request.content.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_message_empty",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Companion message content is empty",
|
||||
));
|
||||
}
|
||||
if content.chars().count() > MAX_MESSAGE_CHARS {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_message_too_large",
|
||||
DiagnosticSeverity::Warning,
|
||||
format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"),
|
||||
));
|
||||
}
|
||||
|
||||
let worker = match self.current_worker() {
|
||||
Ok(Some(worker)) => worker,
|
||||
Ok(None) => {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_worker_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Workspace Companion Worker is unavailable",
|
||||
));
|
||||
}
|
||||
Err(diagnostic) => return self.rejected_message_response(diagnostic),
|
||||
};
|
||||
|
||||
let response = self.runtime.send_input(
|
||||
COMPANION_RUNTIME_ID,
|
||||
&worker.worker_id,
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::User,
|
||||
content: content.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
let state = match result.state {
|
||||
WorkerOperationState::Accepted => CompanionState::Accepted,
|
||||
WorkerOperationState::Unsupported | WorkerOperationState::Rejected => {
|
||||
CompanionState::Rejected
|
||||
}
|
||||
};
|
||||
let diagnostics = if result.diagnostics.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
result.diagnostics.clone()
|
||||
};
|
||||
let projection = self.transcript(0, 200);
|
||||
CompanionMessageResponse {
|
||||
state,
|
||||
worker: projection_worker(&self.status()),
|
||||
user_item: projection
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.role == "user" && item.content == content)
|
||||
.cloned(),
|
||||
assistant_item: projection
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.role == "assistant")
|
||||
.cloned(),
|
||||
transcript: projection,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
Err(error) => self.rejected_message_response(diagnostic(
|
||||
"companion_worker_input_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Companion Worker input dispatch failed: {error:?}"),
|
||||
)),
|
||||
}
|
||||
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||
disabled_message_response(CompanionState::Rejected)
|
||||
}
|
||||
|
||||
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
||||
let diagnostics = vec![diagnostic(
|
||||
"companion_cancel_no_active_run",
|
||||
DiagnosticSeverity::Info,
|
||||
"Workspace Companion has no active generation to cancel",
|
||||
)];
|
||||
let status = self.status();
|
||||
let projection = self.transcript(0, 200);
|
||||
CompanionMessageResponse {
|
||||
state: CompanionState::Cancelled,
|
||||
worker: status.worker,
|
||||
user_item: None,
|
||||
assistant_item: projection
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.role == "assistant")
|
||||
.cloned(),
|
||||
transcript: projection,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
|
||||
let status = self.status();
|
||||
let projection = self.transcript(0, 200);
|
||||
CompanionMessageResponse {
|
||||
state: CompanionState::Rejected,
|
||||
worker: status.worker,
|
||||
user_item: None,
|
||||
assistant_item: projection
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.role == "assistant")
|
||||
.cloned(),
|
||||
transcript: projection,
|
||||
diagnostics: vec![diagnostic],
|
||||
}
|
||||
}
|
||||
|
||||
fn current_worker(&self) -> Result<Option<WorkerSummary>, RuntimeDiagnostic> {
|
||||
self.refresh_worker_state()
|
||||
.map(|state| state.worker.clone())
|
||||
}
|
||||
|
||||
fn refresh_worker_state(&self) -> Result<CompanionWorkerState, RuntimeDiagnostic> {
|
||||
let mut state = self.worker.lock().map_err(|_| {
|
||||
diagnostic(
|
||||
"companion_state_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Companion state is unavailable",
|
||||
)
|
||||
})?;
|
||||
let Some(worker_id) = state.worker.as_ref().map(|worker| worker.worker_id.clone()) else {
|
||||
return Ok(CompanionWorkerState {
|
||||
state: state.state,
|
||||
worker: None,
|
||||
diagnostics: state.diagnostics.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
match self.runtime.worker(COMPANION_RUNTIME_ID, &worker_id) {
|
||||
Ok(worker) => {
|
||||
let mut diagnostics = if worker.capabilities.can_accept_input {
|
||||
Vec::new()
|
||||
} else {
|
||||
state.diagnostics.clone()
|
||||
};
|
||||
if !worker.capabilities.can_accept_input
|
||||
&& !diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "companion_worker_not_input_capable")
|
||||
{
|
||||
diagnostics.push(companion_not_input_capable_diagnostic(&worker));
|
||||
}
|
||||
state.state = companion_state_for_worker(&worker);
|
||||
state.worker = Some(worker);
|
||||
state.diagnostics = diagnostics;
|
||||
}
|
||||
Err(error) => {
|
||||
state.state = CompanionState::Error;
|
||||
state.diagnostics = vec![diagnostic(
|
||||
"companion_worker_lookup_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Companion Worker lookup failed: {error:?}"),
|
||||
)];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CompanionWorkerState {
|
||||
state: state.state,
|
||||
worker: state.worker.clone(),
|
||||
diagnostics: state.diagnostics.clone(),
|
||||
})
|
||||
disabled_message_response(CompanionState::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
fn projection_worker(status: &CompanionStatusResponse) -> Option<WorkerSummary> {
|
||||
status.worker.clone()
|
||||
}
|
||||
|
||||
fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
|
||||
let selector = companion_profile_selector();
|
||||
let mut diagnostics = Vec::new();
|
||||
let config_bundle = companion_config_bundle();
|
||||
|
||||
match runtime.sync_config_bundle(COMPANION_RUNTIME_ID, config_bundle) {
|
||||
Ok(result) => diagnostics.extend(result.diagnostics),
|
||||
Err(error) => diagnostics.push(diagnostic(
|
||||
"companion_config_bundle_sync_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Workspace Companion config bundle sync failed: {error:?}"),
|
||||
)),
|
||||
}
|
||||
|
||||
let response = runtime.spawn_worker(
|
||||
COMPANION_RUNTIME_ID,
|
||||
WorkerSpawnRequest {
|
||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||
requested_worker_name: Some("workspace-companion".to_string()),
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: Some(selector),
|
||||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
},
|
||||
);
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
diagnostics.extend(response.diagnostics);
|
||||
if let Some(worker) = response.worker {
|
||||
if !worker.capabilities.can_accept_input {
|
||||
diagnostics.push(companion_not_input_capable_diagnostic(&worker));
|
||||
}
|
||||
CompanionWorkerState {
|
||||
state: companion_state_for_worker(&worker),
|
||||
worker: Some(worker),
|
||||
diagnostics,
|
||||
}
|
||||
} else {
|
||||
diagnostics.push(diagnostic(
|
||||
"companion_worker_missing",
|
||||
DiagnosticSeverity::Error,
|
||||
"Workspace Companion Worker spawn did not return a Worker projection",
|
||||
));
|
||||
CompanionWorkerState {
|
||||
state: CompanionState::Error,
|
||||
worker: None,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => CompanionWorkerState {
|
||||
state: CompanionState::Error,
|
||||
worker: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_worker_spawn_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Workspace Companion Worker spawn failed: {error:?}"),
|
||||
)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn companion_profile_selector() -> ProfileSelector {
|
||||
ProfileSelector::Builtin(COMPANION_PROFILE_ID.to_string())
|
||||
}
|
||||
|
||||
fn companion_config_bundle() -> ConfigBundle {
|
||||
ConfigBundle {
|
||||
metadata: ConfigBundleMetadata {
|
||||
id: COMPANION_CONFIG_BUNDLE_ID.to_string(),
|
||||
digest: String::new(),
|
||||
revision: "1".to_string(),
|
||||
workspace_id: "workspace-companion".to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
source: "workspace-server".to_string(),
|
||||
detail: Some("workspace-companion".to_string()),
|
||||
},
|
||||
},
|
||||
profiles: vec![ConfigProfileDescriptor {
|
||||
selector: companion_profile_selector(),
|
||||
label: Some("Workspace Companion".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: Some(companion_profile_archive()),
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
||||
fn companion_profile_archive() -> ProfileSourceArchive {
|
||||
let mut entrypoints = BTreeMap::new();
|
||||
entrypoints.insert("default".to_string(), "profiles/companion.dcdl".to_string());
|
||||
entrypoints.insert(
|
||||
COMPANION_PROFILE_ID.to_string(),
|
||||
"profiles/companion.dcdl".to_string(),
|
||||
);
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert(
|
||||
"profiles/companion.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/companion.dcdl").to_string(),
|
||||
);
|
||||
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||
id: "workspace-companion-profile-archive-v1".to_string(),
|
||||
entrypoints,
|
||||
imports: BTreeMap::new(),
|
||||
sources,
|
||||
})
|
||||
.expect("builtin Companion Decodal profile source archive is valid")
|
||||
}
|
||||
|
||||
fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState {
|
||||
if !worker.capabilities.can_accept_input {
|
||||
return CompanionState::Error;
|
||||
}
|
||||
match worker.status.as_str() {
|
||||
"busy" | "running" | "stopping" => CompanionState::Busy,
|
||||
"errored" | "error" | "stopped" | "unavailable" => CompanionState::Error,
|
||||
_ => CompanionState::Ready,
|
||||
}
|
||||
}
|
||||
|
||||
fn companion_not_input_capable_diagnostic(worker: &WorkerSummary) -> RuntimeDiagnostic {
|
||||
diagnostic(
|
||||
"companion_worker_not_input_capable",
|
||||
DiagnosticSeverity::Error,
|
||||
format!(
|
||||
"Workspace Companion Worker '{}' is not input-capable; check profile, provider, secret, and authority diagnostics",
|
||||
worker.worker_id
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_runtime_transcript(
|
||||
transcript: &WorkerTranscriptProjection,
|
||||
state: CompanionState,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
) -> CompanionTranscriptProjection {
|
||||
CompanionTranscriptProjection {
|
||||
fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse {
|
||||
CompanionMessageResponse {
|
||||
state,
|
||||
start: transcript.start,
|
||||
limit: transcript.limit,
|
||||
total_items: transcript.total_items,
|
||||
next_start: transcript.next_start,
|
||||
items: transcript
|
||||
.items
|
||||
.iter()
|
||||
.map(project_runtime_transcript_item)
|
||||
.collect(),
|
||||
diagnostics,
|
||||
worker: None,
|
||||
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 project_runtime_transcript_item(item: &RuntimeTranscriptItem) -> CompanionTranscriptItem {
|
||||
CompanionTranscriptItem {
|
||||
sequence: item.sequence,
|
||||
role: item.role.clone(),
|
||||
content: item.content.clone(),
|
||||
created_at: format!("runtime_sequence:{}", item.sequence),
|
||||
source: "worker_runtime".to_string(),
|
||||
status: "committed".to_string(),
|
||||
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 companion_transport(worker: Option<&WorkerSummary>) -> CompanionTransportSummary {
|
||||
if worker.is_some_and(|worker| worker.capabilities.can_accept_input) {
|
||||
CompanionTransportSummary {
|
||||
kind: "embedded_worker_runtime".to_string(),
|
||||
completion: "connected".to_string(),
|
||||
limitation:
|
||||
"Workspace Companion input is dispatched through the normal Worker runtime path."
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
CompanionTransportSummary {
|
||||
kind: "embedded_worker_runtime".to_string(),
|
||||
completion: "not_input_capable".to_string(),
|
||||
limitation:
|
||||
"Workspace Companion is a Worker but is not input-capable; inspect typed diagnostics for missing profile, provider, secret, or authority."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic(
|
||||
code: impl Into<String>,
|
||||
severity: DiagnosticSeverity,
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeDiagnostic {
|
||||
fn disabled_diagnostic() -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hosts::{EmbeddedWorkerRuntime, RuntimeRegistry};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use worker_runtime::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
|
||||
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionRunState,
|
||||
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use worker_runtime::identity::WorkerRef;
|
||||
use worker_runtime::interaction::WorkerInput;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DeterministicExecutionBackend {
|
||||
contexts: StdMutex<HashMap<WorkerRef, WorkerExecutionContext>>,
|
||||
}
|
||||
|
||||
impl WorkerExecutionBackend for DeterministicExecutionBackend {
|
||||
fn backend_id(&self) -> &str {
|
||||
"deterministic-companion-test"
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||
self.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(request.worker_ref.clone(), request.context);
|
||||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(
|
||||
request.worker_ref.clone(),
|
||||
"deterministic-companion-test",
|
||||
),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
working_directory: request
|
||||
.working_directory
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_input(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
request: WorkerInput,
|
||||
) -> WorkerExecutionResult {
|
||||
let worker = handle.worker_ref().clone();
|
||||
let context = self
|
||||
.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&worker)
|
||||
.cloned()
|
||||
.expect("execution context");
|
||||
let content = request.content.clone();
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
let _ = context.publish_protocol_event(protocol::Event::TextDone {
|
||||
text: format!("companion echoed: {content}"),
|
||||
});
|
||||
});
|
||||
WorkerExecutionResult::accepted(
|
||||
WorkerExecutionOperation::Input,
|
||||
WorkerExecutionRunState::Idle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_spawns_worker_with_companion_profile_through_runtime_backend() {
|
||||
let registry = RuntimeRegistry::for_workspace(
|
||||
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
"local:test",
|
||||
Arc::new(DeterministicExecutionBackend::default()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let registry = Arc::new(registry);
|
||||
let companion = CompanionConsole::new(registry.clone());
|
||||
|
||||
let status = companion.status();
|
||||
let worker = status.worker.clone().expect("companion worker");
|
||||
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
|
||||
assert_eq!(worker.role.as_deref(), Some(COMPANION_PROFILE_ID));
|
||||
assert!(worker.capabilities.can_accept_input);
|
||||
assert_eq!(status.transport.completion, "connected");
|
||||
assert!(status.diagnostics.is_empty());
|
||||
|
||||
let response = companion.send_message(CompanionMessageRequest {
|
||||
content: "hello".to_string(),
|
||||
});
|
||||
assert_eq!(response.state, CompanionState::Accepted);
|
||||
assert!(response.diagnostics.is_empty());
|
||||
assert!(
|
||||
response
|
||||
.transcript
|
||||
.items
|
||||
.iter()
|
||||
.any(|entry| entry.role == "user" && entry.content == "hello")
|
||||
);
|
||||
|
||||
let worker_detail = registry
|
||||
.worker(COMPANION_RUNTIME_ID, &worker.worker_id)
|
||||
.expect("worker detail");
|
||||
assert_eq!(worker_detail.profile.as_deref(), Some(COMPANION_PROFILE_ID));
|
||||
|
||||
let browser_payload = serde_json::to_string(&(status, response, worker_detail)).unwrap();
|
||||
for forbidden in [
|
||||
"/workspace/project",
|
||||
"metadata.json",
|
||||
".jsonl",
|
||||
"/run/user/",
|
||||
"session",
|
||||
"manifest",
|
||||
] {
|
||||
assert!(
|
||||
!browser_payload.contains(forbidden),
|
||||
"companion projection leaked forbidden term {forbidden}: {browser_payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_dispatches_input_and_projects_assistant_output_from_worker_runtime() {
|
||||
let registry = RuntimeRegistry::for_workspace(
|
||||
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
"local:test",
|
||||
Arc::new(DeterministicExecutionBackend::default()),
|
||||
)
|
||||
.expect("embedded runtime"),
|
||||
);
|
||||
let registry = Arc::new(registry);
|
||||
let companion = CompanionConsole::new(registry.clone());
|
||||
let status = companion.status();
|
||||
let worker = status.worker.clone().expect("companion worker");
|
||||
assert_eq!(status.transport.completion, "connected");
|
||||
assert_eq!(worker.profile.as_deref(), Some(COMPANION_PROFILE_ID));
|
||||
assert!(worker.capabilities.can_accept_input);
|
||||
|
||||
let source = registry
|
||||
.observation_source(COMPANION_RUNTIME_ID, &worker.worker_id)
|
||||
.expect("observation source");
|
||||
let crate::observation::RuntimeObservationSource::Embedded(source) = source else {
|
||||
panic!("expected embedded observation source");
|
||||
};
|
||||
let cursor = source
|
||||
.runtime
|
||||
.worker_observation_cursor_now(&source.worker_ref)
|
||||
.expect("observation cursor");
|
||||
|
||||
let response = companion.send_message(CompanionMessageRequest {
|
||||
content: "hello runtime".to_string(),
|
||||
});
|
||||
assert_eq!(response.state, CompanionState::Accepted);
|
||||
assert!(
|
||||
response
|
||||
.user_item
|
||||
.as_ref()
|
||||
.is_some_and(|item| item.role == "user" && item.content == "hello runtime")
|
||||
);
|
||||
assert!(response.diagnostics.is_empty());
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let observed = loop {
|
||||
let observed = source
|
||||
.runtime
|
||||
.read_worker_observation_events(&source.worker_ref, cursor)
|
||||
.expect("observation events");
|
||||
if observed.iter().any(|event| {
|
||||
serde_json::to_string(event)
|
||||
.unwrap()
|
||||
.contains("companion echoed: hello runtime")
|
||||
}) {
|
||||
break observed;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for observation event"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
let observed_json = serde_json::to_string(&observed).unwrap();
|
||||
assert!(observed_json.contains("companion echoed: hello runtime"));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let transcript = loop {
|
||||
let transcript = companion.transcript(0, 20);
|
||||
if transcript.items.iter().any(|item| {
|
||||
item.role == "assistant" && item.content == "companion echoed: hello runtime"
|
||||
}) {
|
||||
break transcript;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for companion assistant output: {transcript:?}"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
|
||||
assert!(
|
||||
transcript
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.role == "user" && item.content == "hello runtime")
|
||||
);
|
||||
assert!(transcript.items.iter().any(|item| {
|
||||
item.role == "assistant"
|
||||
&& item.source == "worker_runtime"
|
||||
&& item.status == "committed"
|
||||
}));
|
||||
|
||||
let runtime_transcript = registry
|
||||
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 20)
|
||||
.expect("runtime transcript");
|
||||
assert!(runtime_transcript.items.iter().any(|item| {
|
||||
item.role == "assistant" && item.content == "companion echoed: hello runtime"
|
||||
}));
|
||||
code: "companion_disabled".to_string(),
|
||||
severity: DiagnosticSeverity::Info,
|
||||
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +261,11 @@ impl<T> RuntimeList<T> {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retired_companion_worker(worker: &WorkerSummary) -> bool {
|
||||
worker.role.as_deref() == Some("builtin:companion")
|
||||
|| worker.profile.as_deref() == Some("builtin:companion")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerLookupResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -874,7 +879,12 @@ impl RuntimeRegistry {
|
||||
}
|
||||
let mut list = runtime.list_workers(limit.saturating_sub(items.len()));
|
||||
diagnostics.append(&mut list.diagnostics);
|
||||
items.append(&mut list.items);
|
||||
items.extend(
|
||||
list.items
|
||||
.into_iter()
|
||||
.filter(|worker| !is_retired_companion_worker(worker))
|
||||
.take(limit.saturating_sub(items.len())),
|
||||
);
|
||||
}
|
||||
diagnostics.truncate(MAX_DIAGNOSTICS);
|
||||
RuntimeList::new(items, diagnostics)
|
||||
@@ -904,6 +914,7 @@ impl RuntimeRegistry {
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|worker| worker.host_id == host_id)
|
||||
.filter(|worker| !is_retired_companion_worker(worker))
|
||||
.take(limit.saturating_sub(items.len())),
|
||||
);
|
||||
if items.len() >= limit {
|
||||
@@ -927,9 +938,16 @@ impl RuntimeRegistry {
|
||||
validate_backend_identifier("worker_id", worker_id)?;
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
let lookup = runtime.worker(worker_id);
|
||||
lookup.worker.ok_or_else(|| {
|
||||
let worker = lookup.worker.ok_or_else(|| {
|
||||
operation_failed_or_unknown_worker(runtime_id, worker_id, lookup.diagnostics)
|
||||
})
|
||||
})?;
|
||||
if is_retired_companion_worker(&worker) {
|
||||
return Err(RuntimeRegistryError::UnknownWorker {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(worker)
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
|
||||
@@ -249,7 +249,7 @@ impl WorkspaceApi {
|
||||
);
|
||||
}
|
||||
let runtime = Arc::new(runtime);
|
||||
let companion = Arc::new(CompanionConsole::new(runtime.clone()));
|
||||
let companion = Arc::new(CompanionConsole::disabled());
|
||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
||||
Ok(Self {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
|
||||
@@ -1590,6 +1590,7 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens
|
||||
)
|
||||
}
|
||||
}
|
||||
"disabled" => "Workspace Companion auto-start has been removed; create an explicit Worker instead.".to_string(),
|
||||
other => format!(
|
||||
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state."
|
||||
),
|
||||
@@ -5064,24 +5065,18 @@ mod tests {
|
||||
|
||||
let workers = get_json(app.clone(), "/api/workers").await;
|
||||
let worker_items = workers["items"].as_array().unwrap();
|
||||
let companion_worker = worker_items
|
||||
.iter()
|
||||
.find(|worker| worker["role"] == "builtin:companion")
|
||||
.expect("companion worker is visible through runtime worker API");
|
||||
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
|
||||
assert!(companion_worker["capabilities"]["can_stop"].is_boolean());
|
||||
assert!(
|
||||
worker_items
|
||||
.iter()
|
||||
.all(|worker| worker["role"] != "builtin:companion"),
|
||||
"companion auto-start should not create runtime workers: {workers}"
|
||||
);
|
||||
|
||||
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
||||
assert!(matches!(
|
||||
companion_status["state"].as_str(),
|
||||
Some("ready") | Some("error")
|
||||
));
|
||||
assert_eq!(companion_status["worker"]["role"], "builtin:companion");
|
||||
assert_eq!(
|
||||
companion_status["transport"]["kind"],
|
||||
"embedded_worker_runtime"
|
||||
);
|
||||
assert_ne!(companion_status["transport"]["completion"], "not_connected");
|
||||
assert_eq!(companion_status["state"], "disabled");
|
||||
assert!(companion_status["worker"].is_null());
|
||||
assert_eq!(companion_status["transport"]["kind"], "none");
|
||||
assert_eq!(companion_status["transport"]["completion"], "disabled");
|
||||
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
||||
|
||||
let companion_message = post_json(
|
||||
@@ -5090,20 +5085,17 @@ mod tests {
|
||||
json!({ "content": "hello companion" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(companion_message["state"], "accepted");
|
||||
assert_eq!(companion_message["user_item"]["role"], "user");
|
||||
assert_eq!(companion_message["user_item"]["content"], "hello companion");
|
||||
assert!(
|
||||
!companion_message
|
||||
.to_string()
|
||||
.contains("companion_llm_not_connected"),
|
||||
"legacy non-execution diagnostic leaked: {companion_message}"
|
||||
assert_eq!(companion_message["state"], "rejected");
|
||||
assert_eq!(
|
||||
companion_message["diagnostics"][0]["code"],
|
||||
"companion_disabled"
|
||||
);
|
||||
assert!(!companion_message.to_string().contains("providerless"));
|
||||
assert!(companion_message["user_item"].is_null());
|
||||
assert!(companion_message["assistant_item"].is_null());
|
||||
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
||||
|
||||
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||
assert!(companion_transcript["total_items"].as_u64().unwrap() >= 1);
|
||||
assert_eq!(companion_transcript["total_items"], 0);
|
||||
|
||||
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
||||
assert!(
|
||||
@@ -5111,7 +5103,7 @@ mod tests {
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|worker| worker["role"] == "builtin:companion")
|
||||
.all(|worker| worker["role"] != "builtin:companion")
|
||||
);
|
||||
|
||||
let runs_response = app
|
||||
@@ -5193,7 +5185,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_companion_messages_route_dispatches_through_worker_runtime() {
|
||||
async fn companion_routes_report_disabled_without_spawning_worker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = test_server_config(temp.path().join("workspace"));
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
@@ -5207,24 +5199,22 @@ mod tests {
|
||||
|
||||
let workspace = get_json(app.clone(), "/api/workspace").await;
|
||||
let workspace_companion = &workspace["extension_points"]["companion_console"];
|
||||
assert_eq!(workspace_companion["status"], "connected");
|
||||
assert!(
|
||||
workspace_companion["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
assert_eq!(workspace_companion["status"], "disabled");
|
||||
assert_eq!(
|
||||
workspace_companion["diagnostics"][0]["code"],
|
||||
"companion_disabled"
|
||||
);
|
||||
assert!(
|
||||
workspace_companion["note"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("normal Worker runtime path")
|
||||
.contains("auto-start has been removed")
|
||||
);
|
||||
|
||||
let status = get_json(app.clone(), "/api/companion/status").await;
|
||||
assert_eq!(status["transport"]["completion"], "connected");
|
||||
let worker_id = status["worker"]["worker_id"].as_str().unwrap().to_string();
|
||||
assert_eq!(status["worker"]["profile"], "builtin:companion");
|
||||
assert_eq!(status["state"], "disabled");
|
||||
assert_eq!(status["transport"]["completion"], "disabled");
|
||||
assert!(status["worker"].is_null());
|
||||
|
||||
let response = post_json(
|
||||
app.clone(),
|
||||
@@ -5232,49 +5222,23 @@ mod tests {
|
||||
serde_json::json!({ "content": "from legacy route" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["state"], "accepted");
|
||||
assert_eq!(response["user_item"]["content"], "from legacy route");
|
||||
assert!(!response.to_string().contains("companion_llm_not_connected"));
|
||||
assert_eq!(response["state"], "rejected");
|
||||
assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
|
||||
assert!(response["user_item"].is_null());
|
||||
assert!(response["assistant_item"].is_null());
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let transcript = loop {
|
||||
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||
let has_assistant = transcript["items"].as_array().unwrap().iter().any(|item| {
|
||||
item["role"] == "assistant"
|
||||
&& item["content"] == "server companion echoed: from legacy route"
|
||||
&& item["source"] == "worker_runtime"
|
||||
});
|
||||
if has_assistant {
|
||||
break transcript;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for server companion transcript: {transcript}"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
};
|
||||
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||
assert_eq!(transcript["state"], "disabled");
|
||||
assert_eq!(transcript["total_items"], 0);
|
||||
|
||||
let workers = get_json(app, "/api/workers").await;
|
||||
assert!(
|
||||
transcript["items"]
|
||||
workers["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| { item["role"] == "user" && item["content"] == "from legacy route" })
|
||||
);
|
||||
|
||||
let worker_transcript = get_json(
|
||||
app,
|
||||
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/transcript"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
worker_transcript["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item["role"] == "assistant"
|
||||
&& item["content"] == "server companion echoed: from legacy route"
|
||||
})
|
||||
.all(|worker| worker["role"] != "builtin:companion"),
|
||||
"disabled companion route should not spawn workers: {workers}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user