fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -22,7 +22,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId};
|
||||
|
||||
/// Identifies one [`Conversation`].
|
||||
///
|
||||
@ -68,19 +68,38 @@ impl ConversationId {
|
||||
/// Pour une paire `Agent↔Agent`, l'id est dérivé des deux UUID agent de façon
|
||||
/// commutative (XOR), stable et déterministe.
|
||||
#[must_use]
|
||||
pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self {
|
||||
pub fn for_project_pair(
|
||||
project_id: ProjectId,
|
||||
a: ConversationParty,
|
||||
b: ConversationParty,
|
||||
) -> Self {
|
||||
match (a.as_agent(), b.as_agent()) {
|
||||
// User↔Agent (un seul agent) : aligné sur le repli de `resolve_conversation`.
|
||||
(Some(agent), None) | (None, Some(agent)) => Self::from_uuid(agent.as_uuid()),
|
||||
(Some(agent), None) | (None, Some(agent)) => {
|
||||
let key = RuntimeAgentKey::new(project_id, agent);
|
||||
Self::from_uuid(uuid::Uuid::from_u128(
|
||||
key.project_id.as_uuid().as_u128() ^ key.agent_id.as_uuid().as_u128(),
|
||||
))
|
||||
}
|
||||
// Agent↔Agent : combinaison commutative des deux ids (insensible à l'ordre).
|
||||
(Some(x), Some(y)) => {
|
||||
let xor = x.as_uuid().as_u128() ^ y.as_uuid().as_u128();
|
||||
let xor =
|
||||
project_id.as_uuid().as_u128() ^ x.as_uuid().as_u128() ^ y.as_uuid().as_u128();
|
||||
Self::from_uuid(uuid::Uuid::from_u128(xor))
|
||||
}
|
||||
// User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic.
|
||||
(None, None) => Self::from_uuid(uuid::Uuid::nil()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy deterministic pair id without project scoping.
|
||||
///
|
||||
/// Runtime code must prefer [`Self::for_project_pair`]. Kept for persisted legacy
|
||||
/// data and tests that intentionally exercise project-less values.
|
||||
#[must_use]
|
||||
pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self {
|
||||
Self::for_project_pair(ProjectId::from_uuid(uuid::Uuid::nil()), a, b)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConversationId {
|
||||
@ -322,7 +341,12 @@ pub trait ConversationRegistry: Send + Sync {
|
||||
/// Get-or-create: returns the thread for the pair `{a, b}`, opening it
|
||||
/// (`Dormant`) if it did not exist. Pure registry — opens **no** session.
|
||||
/// The same unordered pair always yields the same [`ConversationId`].
|
||||
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
|
||||
fn resolve(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
a: ConversationParty,
|
||||
b: ConversationParty,
|
||||
) -> Conversation;
|
||||
|
||||
/// Marks the conversation `id` `Live` with the given session reference.
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef);
|
||||
|
||||
@ -115,3 +115,34 @@ typed_id!(
|
||||
/// Identifies a first-class background task.
|
||||
TaskId
|
||||
);
|
||||
|
||||
/// Runtime-only key for an agent scoped by its project.
|
||||
///
|
||||
/// `AgentId` is persisted inside a project and is not globally unique across
|
||||
/// simultaneously opened projects. Any app-wide in-memory registry that tracks
|
||||
/// live runtime state for an agent must use this key instead of `AgentId` alone.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeAgentKey {
|
||||
/// Project that owns the runtime agent.
|
||||
pub project_id: ProjectId,
|
||||
/// Agent inside that project.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
impl RuntimeAgentKey {
|
||||
/// Builds a scoped runtime key from its persisted identifiers.
|
||||
#[must_use]
|
||||
pub const fn new(project_id: ProjectId, agent_id: AgentId) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
agent_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeAgentKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.project_id, self.agent_id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, TaskId};
|
||||
use crate::ids::{AgentId, RuntimeAgentKey, TaskId};
|
||||
use crate::mailbox::TicketId;
|
||||
|
||||
/// Default bounded inbox capacity per agent.
|
||||
@ -114,6 +114,8 @@ pub struct InboxReceipt {
|
||||
pub item_id: TicketId,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// Runtime-scoped target agent.
|
||||
pub runtime_key: RuntimeAgentKey,
|
||||
/// FIFO depth after the operation.
|
||||
pub depth: usize,
|
||||
/// Enqueue outcome.
|
||||
@ -126,6 +128,8 @@ pub struct InboxReceipt {
|
||||
pub struct AgentInboxSnapshot {
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// Runtime-scoped target agent.
|
||||
pub runtime_key: RuntimeAgentKey,
|
||||
/// Number of queued inbox items.
|
||||
pub depth: usize,
|
||||
/// FIFO-ordered items.
|
||||
@ -162,11 +166,15 @@ pub trait AgentInbox: Send + Sync {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`InboxError`] when the item is invalid or a normal message overflows.
|
||||
fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result<InboxReceipt, InboxError>;
|
||||
fn enqueue_message(
|
||||
&self,
|
||||
agent: RuntimeAgentKey,
|
||||
item: InboxItem,
|
||||
) -> Result<InboxReceipt, InboxError>;
|
||||
|
||||
/// Pops the next queued inbox item, if any.
|
||||
fn dequeue_next(&self, agent: AgentId) -> Option<InboxItem>;
|
||||
fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem>;
|
||||
|
||||
/// Returns the FIFO snapshot for `agent`.
|
||||
fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot;
|
||||
fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot;
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::AgentId;
|
||||
use crate::ids::{AgentId, RuntimeAgentKey};
|
||||
use crate::mailbox::{PendingReply, Ticket, TicketId};
|
||||
use crate::ports::PtyHandle;
|
||||
|
||||
@ -162,7 +162,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
|
||||
/// text + submit sequence through the single PTY writer. The mediator stays the
|
||||
/// authority of the FIFO/busy state and correlation only.
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply;
|
||||
|
||||
/// Headless/system enqueue: appends `ticket` to the same FIFO and marks the agent
|
||||
/// busy, but does **not** deliver any text to the human terminal surface.
|
||||
@ -175,7 +175,7 @@ pub trait InputMediator: Send + Sync {
|
||||
///
|
||||
/// Default keeps compatibility for simple mediators; production overrides it to
|
||||
/// suppress delivery.
|
||||
fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
self.enqueue(agent, ticket)
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// the orchestrator calls this once it has resolved/launched the agent's live
|
||||
/// session for the target conversation. Default: no-op (a mediator that does not
|
||||
/// own the delivery write).
|
||||
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
||||
fn bind_handle(&self, _agent: RuntimeAgentKey, _handle: PtyHandle) {}
|
||||
|
||||
/// Like [`InputMediator::bind_handle`], but also records the target's
|
||||
/// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo
|
||||
@ -203,7 +203,12 @@ pub trait InputMediator: Send + Sync {
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config.
|
||||
/// The infra adapter overrides it to stash the submit config.
|
||||
fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) {
|
||||
fn bind_handle_with_submit(
|
||||
&self,
|
||||
agent: RuntimeAgentKey,
|
||||
handle: PtyHandle,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
self.bind_handle(agent, handle);
|
||||
}
|
||||
|
||||
@ -212,7 +217,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// for source compatibility; it now always returns `false`. Callers should stop
|
||||
/// branching on it (the orchestrator no longer falls back to its own PTY write).
|
||||
#[must_use]
|
||||
fn delivers_turn(&self, _agent: AgentId) -> bool {
|
||||
fn delivers_turn(&self, _agent: RuntimeAgentKey) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@ -228,7 +233,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// (chemin chaud, fallback sûr, zéro régression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not gate cold starts).
|
||||
fn mark_starting(&self, _agent: AgentId) {}
|
||||
fn mark_starting(&self, _agent: RuntimeAgentKey) {}
|
||||
|
||||
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
|
||||
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
|
||||
@ -240,7 +245,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// watcher prompt-ready PTY ayant été supprimé.
|
||||
///
|
||||
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
|
||||
fn release_cold_start(&self, _agent: AgentId) {}
|
||||
fn release_cold_start(&self, _agent: RuntimeAgentKey) {}
|
||||
|
||||
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
|
||||
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
|
||||
@ -253,14 +258,14 @@ pub trait InputMediator: Send + Sync {
|
||||
///
|
||||
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
|
||||
/// l'événement, comportement historique).
|
||||
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
|
||||
fn set_front_attached(&self, _agent: RuntimeAgentKey, _attached: bool) {}
|
||||
|
||||
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
|
||||
/// is **not** an enqueue and correlates **no** ticket.
|
||||
fn preempt(&self, agent: AgentId);
|
||||
fn preempt(&self, agent: RuntimeAgentKey);
|
||||
|
||||
/// Marks `agent` free (explicit signal) so its FIFO advances.
|
||||
fn mark_idle(&self, agent: AgentId);
|
||||
fn mark_idle(&self, agent: RuntimeAgentKey);
|
||||
|
||||
/// **End-of-turn** signal: the agent's transcript just recorded a completed turn
|
||||
/// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a
|
||||
@ -273,7 +278,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Replaces the former prompt-ready watcher branch verbatim; only the **trigger**
|
||||
/// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default:
|
||||
/// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances).
|
||||
fn turn_ended(&self, agent: AgentId) {
|
||||
fn turn_ended(&self, agent: RuntimeAgentKey) {
|
||||
self.mark_idle(agent);
|
||||
}
|
||||
|
||||
@ -287,7 +292,7 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Default: no-op (a mediator that does not track liveness). The infra adapter
|
||||
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
|
||||
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
|
||||
fn mark_alive(&self, _agent: AgentId) {}
|
||||
fn mark_alive(&self, _agent: RuntimeAgentKey) {}
|
||||
|
||||
/// Declares the agent's **stall threshold** (its profile's
|
||||
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
|
||||
@ -298,10 +303,10 @@ pub trait InputMediator: Send + Sync {
|
||||
/// zero regression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness).
|
||||
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
|
||||
fn set_stall_threshold(&self, _agent: RuntimeAgentKey, _stall_after_ms: Option<u32>) {}
|
||||
|
||||
/// The current [`AgentBusyState`] of `agent`.
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
||||
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -76,8 +76,8 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId,
|
||||
SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
|
||||
ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
|
||||
@ -30,7 +30,7 @@ use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use crate::conversation::ConversationId;
|
||||
use crate::ids::AgentId;
|
||||
use crate::ids::{AgentId, RuntimeAgentKey};
|
||||
use crate::input::InputSource;
|
||||
|
||||
/// A read-only, cloned view of one queued [`Ticket`] in a target agent's FIFO.
|
||||
@ -71,7 +71,7 @@ pub trait AgentQueueSnapshot: Send + Sync {
|
||||
///
|
||||
/// An agent with no queue yields an empty `Vec`. Positions are recomputed from
|
||||
/// the current order (`0` = head). Pure read: the queue is left untouched.
|
||||
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot>;
|
||||
fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot>;
|
||||
}
|
||||
|
||||
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
|
||||
@ -283,7 +283,7 @@ pub trait AgentMailbox: Send + Sync {
|
||||
/// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`]
|
||||
/// feeds a result to this ticket (once it reaches the head and is answered), or
|
||||
/// to [`MailboxError::Cancelled`] if the reply channel closes first.
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply;
|
||||
|
||||
/// Resolves the request at the **head** of `agent`'s FIFO with `result`,
|
||||
/// waking its awaiting [`PendingReply`] and removing it from the queue.
|
||||
@ -294,7 +294,7 @@ pub trait AgentMailbox: Send + Sync {
|
||||
/// # Errors
|
||||
/// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an
|
||||
/// `idea_reply` with no matching ask in flight).
|
||||
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>;
|
||||
fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError>;
|
||||
|
||||
/// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO
|
||||
/// with `result`, waking its awaiting [`PendingReply`] and removing it from the
|
||||
@ -314,7 +314,7 @@ pub trait AgentMailbox: Send + Sync {
|
||||
/// (and, for the default, when `agent`'s queue is empty).
|
||||
fn resolve_ticket(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
agent: RuntimeAgentKey,
|
||||
_ticket_id: TicketId,
|
||||
result: String,
|
||||
) -> Result<(), MailboxError> {
|
||||
@ -326,7 +326,7 @@ pub trait AgentMailbox: Send + Sync {
|
||||
///
|
||||
/// A no-op when the head is a different ticket (the timed-out one was already
|
||||
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
|
||||
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId);
|
||||
|
||||
/// Completes the turn of the ticket `ticket_id` **without** a reply, waking its
|
||||
/// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`].
|
||||
@ -346,7 +346,7 @@ pub trait AgentMailbox: Send + Sync {
|
||||
///
|
||||
/// The default is a no-op: a mailbox that cannot wake a pending caller early simply
|
||||
/// falls back to the caller's timeout net (no behavioural change for it).
|
||||
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
|
||||
let _ = (agent, ticket_id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -658,6 +658,8 @@ pub enum ScheduledTask {
|
||||
/// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
|
||||
/// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
|
||||
ResumeAgent {
|
||||
/// Projet hôte de l'agent à reprendre.
|
||||
project_id: ProjectId,
|
||||
/// L'agent à reprendre.
|
||||
agent_id: AgentId,
|
||||
/// La cellule (nœud du layout) qui héberge sa session.
|
||||
|
||||
@ -1415,9 +1415,7 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn opencode_provider_config_rejects_empty_fields() {
|
||||
let secret_ref = crate::ports::SecretRef::new("secret-1");
|
||||
assert!(
|
||||
OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err()
|
||||
);
|
||||
assert!(OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err());
|
||||
assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err());
|
||||
}
|
||||
|
||||
@ -1448,7 +1446,12 @@ mod mcp_tests {
|
||||
|
||||
#[test]
|
||||
fn profile_with_opencode_provider_round_trips_camelcase() {
|
||||
let provider = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-anthropic")).unwrap();
|
||||
let provider = OpenCodeProviderConfig::new(
|
||||
"anthropic",
|
||||
"claude-sonnet-5",
|
||||
crate::ports::SecretRef::new("secret-anthropic"),
|
||||
)
|
||||
.unwrap();
|
||||
let profile = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode_provider(provider.clone());
|
||||
@ -1469,7 +1472,12 @@ mod mcp_tests {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new(
|
||||
"anthropic",
|
||||
"claude-sonnet-5",
|
||||
crate::ports::SecretRef::new("secret-cloud"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let only_local = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
@ -1504,7 +1512,12 @@ mod mcp_tests {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new(
|
||||
"anthropic",
|
||||
"claude-sonnet-5",
|
||||
crate::ports::SecretRef::new("secret-cloud"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Setting the cloud provider clears a previously-set local one.
|
||||
let cloud_wins = profile_without_mcp()
|
||||
@ -1513,7 +1526,10 @@ mod mcp_tests {
|
||||
.with_opencode_provider(cloud.clone());
|
||||
assert!(cloud_wins.opencode_backend_is_consistent());
|
||||
assert!(cloud_wins.opencode.is_none(), "stale local backend dropped");
|
||||
assert_eq!(cloud_wins.opencode_provider.as_ref().unwrap().provider_id, "anthropic");
|
||||
assert_eq!(
|
||||
cloud_wins.opencode_provider.as_ref().unwrap().provider_id,
|
||||
"anthropic"
|
||||
);
|
||||
|
||||
// Setting the local provider clears a previously-set cloud one.
|
||||
let local_wins = profile_without_mcp()
|
||||
@ -1521,13 +1537,21 @@ mod mcp_tests {
|
||||
.with_opencode_provider(cloud)
|
||||
.with_opencode(local);
|
||||
assert!(local_wins.opencode_backend_is_consistent());
|
||||
assert!(local_wins.opencode_provider.is_none(), "stale cloud backend dropped");
|
||||
assert!(
|
||||
local_wins.opencode_provider.is_none(),
|
||||
"stale cloud backend dropped"
|
||||
);
|
||||
assert!(local_wins.opencode.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_provider_config_serialises_no_local_model_server_id_leak() {
|
||||
let config = OpenCodeProviderConfig::new("openrouter", "some-model", crate::ports::SecretRef::new("secret-openrouter")).unwrap();
|
||||
let config = OpenCodeProviderConfig::new(
|
||||
"openrouter",
|
||||
"some-model",
|
||||
crate::ports::SecretRef::new("secret-openrouter"),
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_string(&config).expect("serialise");
|
||||
assert!(!json.contains("localModelServerId"));
|
||||
assert!(!json.contains("baseURL"));
|
||||
|
||||
Reference in New Issue
Block a user