feat: page console history across compaction
This commit is contained in:
@@ -831,6 +831,34 @@ pub struct SessionSnapshot {
|
|||||||
pub entries: Vec<SessionSnapshotEntry>,
|
pub entries: Vec<SessionSnapshotEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One user-visible conversation turn. A turn starts at a canonical user entry
|
||||||
|
/// and contains every public committed entry until the next user entry.
|
||||||
|
/// `turn_id` is the stable `entry_id` of that first user entry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
pub struct SessionConversationTurn {
|
||||||
|
pub turn_id: String,
|
||||||
|
pub entries: Vec<SessionSnapshotEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded backward page over the adopted conversation lineage.
|
||||||
|
///
|
||||||
|
/// Turns remain chronological within a page. `next_cursor` is an opaque,
|
||||||
|
/// identity-bound boundary for the page immediately preceding `turns`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
pub struct SessionHistoryPage {
|
||||||
|
pub session_id: String,
|
||||||
|
/// Stable identity of the adopted active lineage used to fence stale pages.
|
||||||
|
pub lineage_id: String,
|
||||||
|
pub turns: Vec<SessionConversationTurn>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
@@ -957,8 +985,17 @@ pub enum Event {
|
|||||||
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
|
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
|
||||||
/// instances show the same user line that reconnect snapshots replay.
|
/// instances show the same user line that reconnect snapshots replay.
|
||||||
UserMessage {
|
UserMessage {
|
||||||
|
/// Stable durable history identity. Older mixed-version producers omit it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
entry_id: Option<String>,
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
},
|
},
|
||||||
|
/// A canonical assistant/tool entry has committed to durable history.
|
||||||
|
/// Streaming events remain the in-flight authority; this event supplies the
|
||||||
|
/// stable identity used to reconcile that live block with paged history.
|
||||||
|
SessionEntryCommitted {
|
||||||
|
entry: SessionSnapshotEntry,
|
||||||
|
},
|
||||||
/// One agent-injected system item committed to history.
|
/// One agent-injected system item committed to history.
|
||||||
///
|
///
|
||||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||||
@@ -2759,6 +2796,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn event_user_message_roundtrip() {
|
fn event_user_message_roundtrip() {
|
||||||
let event = Event::UserMessage {
|
let event = Event::UserMessage {
|
||||||
|
entry_id: Some("entry-1".to_string()),
|
||||||
segments: vec![Segment::text("hello 世界")],
|
segments: vec![Segment::text("hello 世界")],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
@@ -2769,7 +2807,7 @@ mod tests {
|
|||||||
|
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::UserMessage { segments } => {
|
Event::UserMessage { segments, .. } => {
|
||||||
assert_eq!(segments.len(), 1);
|
assert_eq!(segments.len(), 1);
|
||||||
match &segments[0] {
|
match &segments[0] {
|
||||||
Segment::Text { content } => assert_eq!(content, "hello 世界"),
|
Segment::Text { content } => assert_eq!(content, "hello 世界"),
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ use crate::{
|
|||||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
||||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
|
||||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
||||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||||
|
WorkerStateSnapshot, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
||||||
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
|
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
|
||||||
@@ -104,6 +105,8 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
||||||
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
||||||
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionConversationTurn>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionHistoryPage>(&cfg, &mut output);
|
||||||
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
||||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -655,6 +655,41 @@ pub struct WorkerSessionRequest {
|
|||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerSessionHistoryRequest {
|
||||||
|
pub workspace_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerSessionHistoryUnavailableReason {
|
||||||
|
RetentionMissing,
|
||||||
|
ActivePointerMissing,
|
||||||
|
CorruptLog,
|
||||||
|
MigrationRequired,
|
||||||
|
RetentionExpired,
|
||||||
|
StorageUnavailable,
|
||||||
|
InvalidCursor,
|
||||||
|
ResourceLimit,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "availability", rename_all = "snake_case")]
|
||||||
|
pub enum WorkerSessionHistoryAvailability {
|
||||||
|
Page {
|
||||||
|
page: protocol::SessionHistoryPage,
|
||||||
|
},
|
||||||
|
Unavailable {
|
||||||
|
reason: WorkerSessionHistoryUnavailableReason,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RetainedSessionIdentity {
|
pub struct RetainedSessionIdentity {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
@@ -718,6 +753,17 @@ pub trait RuntimeApi {
|
|||||||
#[query] request: WorkerSessionRequest,
|
#[query] request: WorkerSessionRequest,
|
||||||
) -> Result<WorkerSessionAvailability, RuntimeApiError>;
|
) -> Result<WorkerSessionAvailability, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[get(
|
||||||
|
"/v1/workers/{worker_id}/session/history",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400
|
||||||
|
)]
|
||||||
|
async fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[query] request: WorkerSessionHistoryRequest,
|
||||||
|
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
|
||||||
|
|
||||||
#[post("/v1/workers", status = 200, error_status = 400)]
|
#[post("/v1/workers", status = 200, error_status = 400)]
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
@@ -1053,6 +1099,17 @@ mod tests {
|
|||||||
Ok(WorkerSessionAvailability::LiveProtocol)
|
Ok(WorkerSessionAvailability::LiveProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
_worker_id: String,
|
||||||
|
_request: WorkerSessionHistoryRequest,
|
||||||
|
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError> {
|
||||||
|
Ok(WorkerSessionHistoryAvailability::Unavailable {
|
||||||
|
reason: WorkerSessionHistoryUnavailableReason::Unsupported,
|
||||||
|
message: "unsupported".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
_request: CreateWorkerRequest,
|
_request: CreateWorkerRequest,
|
||||||
@@ -1174,10 +1231,22 @@ mod tests {
|
|||||||
assert_eq!(value["message"], "snapshot exceeds limit");
|
assert_eq!(value["message"], "snapshot exceeds limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_session_history_availability_has_closed_tagged_wire_shape() {
|
||||||
|
let value = serde_json::to_value(WorkerSessionHistoryAvailability::Unavailable {
|
||||||
|
reason: WorkerSessionHistoryUnavailableReason::InvalidCursor,
|
||||||
|
message: "cursor is stale".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value["availability"], "unavailable");
|
||||||
|
assert_eq!(value["reason"], "invalid_cursor");
|
||||||
|
assert_eq!(value["message"], "cursor is stale");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contract_inventory_is_complete_and_unique() {
|
fn contract_inventory_is_complete_and_unique() {
|
||||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||||
assert_eq!(operations.len(), 16);
|
assert_eq!(operations.len(), 17);
|
||||||
let mut routes = operations
|
let mut routes = operations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||||
|
|||||||
@@ -389,6 +389,20 @@ pub struct WorkspaceWorkerSessionResponse {
|
|||||||
pub observation: runtime_api::WorkerSessionAvailability,
|
pub observation: runtime_api::WorkerSessionAvailability,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceWorkerSessionHistoryQuery {
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkspaceWorkerSessionHistoryResponse {
|
||||||
|
pub subject: WorkspaceWorkerSubject,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub observation: runtime_api::WorkerSessionHistoryAvailability,
|
||||||
|
}
|
||||||
|
|
||||||
#[api(reqwest, axum, openapi)]
|
#[api(reqwest, axum, openapi)]
|
||||||
pub trait ServerApi {
|
pub trait ServerApi {
|
||||||
#[get("/health", status = 200, error_status = 400)]
|
#[get("/health", status = 200, error_status = 400)]
|
||||||
@@ -1141,6 +1155,20 @@ pub trait ServerApi {
|
|||||||
#[path] worker_id: String,
|
#[path] worker_id: String,
|
||||||
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
|
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
|
||||||
|
|
||||||
|
#[get(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session/history",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
openapi = false
|
||||||
|
)]
|
||||||
|
async fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] runtime_id: String,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[query] query: WorkspaceWorkerSessionHistoryQuery,
|
||||||
|
) -> Result<WorkspaceWorkerSessionHistoryResponse, ServerApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/workers",
|
"/api/workers",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -11506,7 +11534,7 @@ mod openapi_artifact_tests {
|
|||||||
];
|
];
|
||||||
// Session observation retains its existing explicit non-OpenAPI boundary independently of
|
// Session observation retains its existing explicit non-OpenAPI boundary independently of
|
||||||
// the Runtime/Worker-source signed operation inventory above.
|
// the Runtime/Worker-source signed operation inventory above.
|
||||||
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"];
|
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"];
|
||||||
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
|
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
|
||||||
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
|
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
|
||||||
|
|
||||||
|
|||||||
@@ -37,23 +37,31 @@ use std::path::Path;
|
|||||||
/// Read an existing retained file without updating its access timestamp.
|
/// Read an existing retained file without updating its access timestamp.
|
||||||
/// Observation fails closed when the platform cannot provide that guarantee.
|
/// Observation fails closed when the platform cannot provide that guarantee.
|
||||||
pub(crate) fn read_without_atime(path: &Path) -> io::Result<Vec<u8>> {
|
pub(crate) fn read_without_atime(path: &Path) -> io::Result<Vec<u8>> {
|
||||||
|
read_without_atime_bounded(path, u64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read at most `max_bytes + 1` retained bytes without updating access time.
|
||||||
|
/// The extra byte lets callers prove that the authoritative file exceeded a
|
||||||
|
/// limit without first racing a separate metadata length observation.
|
||||||
|
pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
{
|
{
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
let mut file = OpenOptions::new()
|
let file = OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.custom_flags(libc::O_NOATIME)
|
.custom_flags(libc::O_NOATIME)
|
||||||
.open(path)?;
|
.open(path)?;
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
file.read_to_end(&mut bytes)?;
|
file.take(max_bytes.saturating_add(1))
|
||||||
|
.read_to_end(&mut bytes)?;
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
{
|
{
|
||||||
let _ = path;
|
let _ = (path, max_bytes);
|
||||||
Err(io::Error::new(
|
Err(io::Error::new(
|
||||||
io::ErrorKind::Unsupported,
|
io::ErrorKind::Unsupported,
|
||||||
"read-only retained observation requires no-atime file reads",
|
"read-only retained observation requires no-atime file reads",
|
||||||
@@ -88,8 +96,12 @@ pub use history::{
|
|||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
pub use paste_artifact::PasteArtifactLimits;
|
pub use paste_artifact::PasteArtifactLimits;
|
||||||
pub use public_snapshot::{
|
pub use public_snapshot::{
|
||||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot,
|
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||||
RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot,
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
||||||
|
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||||
|
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedHistoryReadError, RetainedHistoryReadLimits,
|
||||||
|
RetainedSessionIdentity, RetainedSessionSnapshot, RetainedSnapshotReadError,
|
||||||
|
project_session_snapshot, read_retained_session_history_page, read_retained_session_snapshot,
|
||||||
};
|
};
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,9 @@ pub enum StoreError {
|
|||||||
#[error("log corrupted at line {line}: {message}")]
|
#[error("log corrupted at line {line}: {message}")]
|
||||||
Corrupt { line: usize, message: String },
|
Corrupt { line: usize, message: String },
|
||||||
|
|
||||||
|
#[error("retained observation read limit exceeded")]
|
||||||
|
ReadLimitExceeded,
|
||||||
|
|
||||||
#[error("paste artifact storage is unavailable")]
|
#[error("paste artifact storage is unavailable")]
|
||||||
PasteArtifactUnsupported,
|
PasteArtifactUnsupported,
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ impl WorkerSessionStore {
|
|||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<Vec<LogEntry>, StoreError> {
|
) -> Result<Vec<LogEntry>, StoreError> {
|
||||||
|
self.read_all_read_only_bounded(session_id, segment_id, u64::MAX)
|
||||||
|
.map(|(entries, _)| entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one retained Segment under an actual byte bound without touching
|
||||||
|
/// access time. The file descriptor read, rather than a preceding stat, is
|
||||||
|
/// the limit authority so concurrent appends cannot evade accounting.
|
||||||
|
pub fn read_all_read_only_bounded(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
max_bytes: u64,
|
||||||
|
) -> Result<(Vec<LogEntry>, u64), StoreError> {
|
||||||
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
|
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
|
||||||
line: 0,
|
line: 0,
|
||||||
message: "Worker Session identity lock poisoned".to_string(),
|
message: "Worker Session identity lock poisoned".to_string(),
|
||||||
@@ -123,14 +136,18 @@ impl WorkerSessionStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let path = self.log_path(segment_id);
|
let path = self.log_path(segment_id);
|
||||||
let bytes = crate::read_without_atime(&path).map_err(|error| {
|
let bytes = crate::read_without_atime_bounded(&path, max_bytes).map_err(|error| {
|
||||||
if error.kind() == std::io::ErrorKind::NotFound {
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
StoreError::NotFound(segment_id)
|
StoreError::NotFound(segment_id)
|
||||||
} else {
|
} else {
|
||||||
StoreError::Io(error)
|
StoreError::Io(error)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
parse_jsonl(&bytes)
|
let byte_len = bytes.len() as u64;
|
||||||
|
if byte_len > max_bytes {
|
||||||
|
return Err(StoreError::ReadLimitExceeded);
|
||||||
|
}
|
||||||
|
Ok((parse_jsonl(&bytes)?, byte_len))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
|||||||
.expect("protocol event")
|
.expect("protocol event")
|
||||||
.expect("worker event")
|
.expect("worker event")
|
||||||
{
|
{
|
||||||
Event::UserMessage { segments }
|
Event::UserMessage { segments, .. }
|
||||||
if format!("{segments:?}").contains("read the probe") =>
|
if format!("{segments:?}").contains("read the probe") =>
|
||||||
{
|
{
|
||||||
saw_user_message = true;
|
saw_user_message = true;
|
||||||
|
|||||||
@@ -1156,7 +1156,7 @@ impl App {
|
|||||||
Event::PendingSubmissionsChanged { pending } => {
|
Event::PendingSubmissionsChanged { pending } => {
|
||||||
self.pending_submissions = pending;
|
self.pending_submissions = pending;
|
||||||
}
|
}
|
||||||
Event::UserMessage { segments } => {
|
Event::UserMessage { segments, .. } => {
|
||||||
self.turn_index += 1;
|
self.turn_index += 1;
|
||||||
self.blocks.push(Block::TurnHeader {
|
self.blocks.push(Block::TurnHeader {
|
||||||
turn: self.turn_index,
|
turn: self.turn_index,
|
||||||
@@ -1176,6 +1176,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
|
// The TUI already renders assistant/tool streaming events. This commit
|
||||||
|
// marker exists so identity-aware clients can reconcile durable history.
|
||||||
|
Event::SessionEntryCommitted { .. } => {}
|
||||||
Event::SystemItem { item } => {
|
Event::SystemItem { item } => {
|
||||||
self.apply_system_item(&item);
|
self.apply_system_item(&item);
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
@@ -3399,6 +3402,7 @@ mod completion_flow_tests {
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
app.handle_worker_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
|
entry_id: None,
|
||||||
segments: vec![Segment::text("first persisted message")],
|
segments: vec![Segment::text("first persisted message")],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3417,6 +3421,7 @@ mod completion_flow_tests {
|
|||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
|
|
||||||
app.handle_worker_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
|
entry_id: None,
|
||||||
segments: submitted,
|
segments: submitted,
|
||||||
});
|
});
|
||||||
// Simulate run-derived attachment display after the submitted user line.
|
// Simulate run-derived attachment display after the submitted user line.
|
||||||
@@ -3457,6 +3462,7 @@ mod completion_flow_tests {
|
|||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let submitted = submit_text(&mut app, "original submit");
|
let submitted = submit_text(&mut app, "original submit");
|
||||||
app.handle_worker_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
|
entry_id: None,
|
||||||
segments: submitted,
|
segments: submitted,
|
||||||
});
|
});
|
||||||
for c in "draft while running".chars() {
|
for c in "draft while running".chars() {
|
||||||
@@ -3485,6 +3491,7 @@ mod completion_flow_tests {
|
|||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let submitted = submit_text(&mut app, "normal run");
|
let submitted = submit_text(&mut app, "normal run");
|
||||||
app.handle_worker_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
|
entry_id: None,
|
||||||
segments: submitted,
|
segments: submitted,
|
||||||
});
|
});
|
||||||
app.handle_worker_event(Event::RunEnd { result });
|
app.handle_worker_event(Event::RunEnd { result });
|
||||||
|
|||||||
@@ -1688,6 +1688,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
connection.observe_worker_event(&Event::UserMessage {
|
connection.observe_worker_event(&Event::UserMessage {
|
||||||
|
entry_id: None,
|
||||||
segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }],
|
segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }],
|
||||||
});
|
});
|
||||||
assert!(connection.pending_attachments.is_empty());
|
assert!(connection.pending_attachments.is_empty());
|
||||||
@@ -1776,7 +1777,10 @@ mod tests {
|
|||||||
assert!(!reconcile_attachment_submission(
|
assert!(!reconcile_attachment_submission(
|
||||||
&mut pending,
|
&mut pending,
|
||||||
&mut awaiting,
|
&mut awaiting,
|
||||||
&Event::UserMessage { segments },
|
&Event::UserMessage {
|
||||||
|
entry_id: None,
|
||||||
|
segments,
|
||||||
|
},
|
||||||
));
|
));
|
||||||
assert_eq!(awaiting, expected);
|
assert_eq!(awaiting, expected);
|
||||||
assert!(pending.is_empty());
|
assert!(pending.is_empty());
|
||||||
|
|||||||
@@ -477,6 +477,13 @@ pub struct WorkerSessionObservationRequest {
|
|||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WorkerSessionHistoryRequest {
|
||||||
|
pub worker_ref: WorkerRef,
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||||
fn backend_id(&self) -> &str;
|
fn backend_id(&self) -> &str;
|
||||||
|
|
||||||
@@ -497,6 +504,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
_request: WorkerSessionHistoryRequest,
|
||||||
|
) -> runtime_api::WorkerSessionHistoryAvailability {
|
||||||
|
runtime_api::WorkerSessionHistoryAvailability::Unavailable {
|
||||||
|
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
|
||||||
|
message: "session history paging is not supported by this Runtime".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
||||||
|
|
||||||
/// Read-only restore validation performed before any live controller,
|
/// Read-only restore validation performed before any live controller,
|
||||||
@@ -726,6 +743,13 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.worker_session(request)
|
self.backend.worker_session(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
request: WorkerSessionHistoryRequest,
|
||||||
|
) -> runtime_api::WorkerSessionHistoryAvailability {
|
||||||
|
self.backend.worker_session_history(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn spawn_worker(
|
pub(crate) fn spawn_worker(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
|
|||||||
@@ -260,6 +260,35 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
|||||||
.map_err(api_error)
|
.map_err(api_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
request: runtime_api::WorkerSessionHistoryRequest,
|
||||||
|
) -> Result<runtime_api::WorkerSessionHistoryAvailability, runtime_api::RuntimeApiError> {
|
||||||
|
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||||
|
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||||
|
.map_err(api_error)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"runtime_worker_session_scope_required",
|
||||||
|
"Worker Session history requires a Workspace-scoped capability",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if scope.workspace_id != request.workspace_id {
|
||||||
|
return Err(runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"runtime_worker_session_workspace_scope_mismatch",
|
||||||
|
"Worker Session Workspace scope does not match the authenticated capability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.state
|
||||||
|
.runtime
|
||||||
|
.worker_session_history_scoped(&scope, &worker_ref, request.cursor, request.limit)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)
|
||||||
|
.map_err(api_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
value: runtime_api::CreateWorkerRequest,
|
value: runtime_api::CreateWorkerRequest,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ use crate::execution::{
|
|||||||
WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle,
|
WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle,
|
||||||
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest,
|
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest,
|
||||||
WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId,
|
WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId,
|
||||||
WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult,
|
WorkerSessionHistoryRequest, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest,
|
||||||
|
WorkspaceConfigFetchResult,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
use crate::fs_store::{
|
use crate::fs_store::{
|
||||||
@@ -1613,6 +1614,38 @@ impl Runtime {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read one bounded page from the adopted durable Session lineage without
|
||||||
|
/// restoring the Worker or modifying model context.
|
||||||
|
pub fn worker_session_history_scoped(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<runtime_api::WorkerSessionHistoryAvailability, RuntimeError> {
|
||||||
|
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
|
||||||
|
let _operation_guard = operation_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RuntimeError::StatePoisoned)?;
|
||||||
|
self.ensure_worker_in_workspace(scope, worker_ref)?;
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.worker(worker_ref)?;
|
||||||
|
state.execution_backend.clone()
|
||||||
|
};
|
||||||
|
Ok(match backend {
|
||||||
|
Some(backend) => backend.worker_session_history(WorkerSessionHistoryRequest {
|
||||||
|
worker_ref: worker_ref.clone(),
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
None => runtime_api::WorkerSessionHistoryAvailability::Unavailable {
|
||||||
|
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
|
||||||
|
message: "session history paging is not supported by this Runtime".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
||||||
pub fn restore_worker_scoped(
|
pub fn restore_worker_scoped(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -172,6 +172,15 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
|||||||
Err(session_store::RetainedSnapshotReadError::RetentionMissing)
|
Err(session_store::RetainedSnapshotReadError::RetentionMissing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_session_history_page(
|
||||||
|
&self,
|
||||||
|
_worker_ref: &WorkerRef,
|
||||||
|
_cursor: Option<&str>,
|
||||||
|
_limit: Option<usize>,
|
||||||
|
) -> Result<protocol::SessionHistoryPage, session_store::RetainedHistoryReadError> {
|
||||||
|
Err(session_store::RetainedHistoryReadError::RetentionMissing)
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_controller(
|
async fn spawn_controller(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
@@ -1027,6 +1036,25 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_session_history_page(
|
||||||
|
&self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<protocol::SessionHistoryPage, session_store::RetainedHistoryReadError> {
|
||||||
|
let aggregate_dir = self
|
||||||
|
.worker_aggregate_dir(worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedHistoryReadError::RetentionMissing)?;
|
||||||
|
let worker_name = Self::runtime_worker_name_for_ref(worker_ref);
|
||||||
|
session_store::read_retained_session_history_page(
|
||||||
|
&aggregate_dir,
|
||||||
|
&worker_name,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
session_store::RetainedHistoryReadLimits::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_workspace_prompt_projection(
|
fn observe_workspace_prompt_projection(
|
||||||
&self,
|
&self,
|
||||||
projection: worker::WorkspacePromptProjection,
|
projection: worker::WorkspacePromptProjection,
|
||||||
@@ -2216,6 +2244,48 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
request: crate::execution::WorkerSessionHistoryRequest,
|
||||||
|
) -> runtime_api::WorkerSessionHistoryAvailability {
|
||||||
|
match self.factory.retained_session_history_page(
|
||||||
|
&request.worker_ref,
|
||||||
|
request.cursor.as_deref(),
|
||||||
|
request.limit,
|
||||||
|
) {
|
||||||
|
Ok(page) => runtime_api::WorkerSessionHistoryAvailability::Page { page },
|
||||||
|
Err(error) => {
|
||||||
|
let reason = match error {
|
||||||
|
session_store::RetainedHistoryReadError::RetentionMissing => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::RetentionMissing
|
||||||
|
}
|
||||||
|
session_store::RetainedHistoryReadError::ActivePointerMissing => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::ActivePointerMissing
|
||||||
|
}
|
||||||
|
session_store::RetainedHistoryReadError::MigrationRequired => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::MigrationRequired
|
||||||
|
}
|
||||||
|
session_store::RetainedHistoryReadError::CorruptLog => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::CorruptLog
|
||||||
|
}
|
||||||
|
session_store::RetainedHistoryReadError::StorageUnavailable => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::StorageUnavailable
|
||||||
|
}
|
||||||
|
session_store::RetainedHistoryReadError::InvalidCursor => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::InvalidCursor
|
||||||
|
}
|
||||||
|
session_store::RetainedHistoryReadError::ResourceLimit => {
|
||||||
|
runtime_api::WorkerSessionHistoryUnavailableReason::ResourceLimit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runtime_api::WorkerSessionHistoryAvailability::Unavailable {
|
||||||
|
reason,
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_workspace_prompt_projection(
|
fn observe_workspace_prompt_projection(
|
||||||
&self,
|
&self,
|
||||||
projection: worker::WorkspacePromptProjection,
|
projection: worker::WorkspacePromptProjection,
|
||||||
|
|||||||
@@ -619,8 +619,8 @@ mod tests {
|
|||||||
let (committed_tx, committed_rx) = mpsc::channel();
|
let (committed_tx, committed_rx) = mpsc::channel();
|
||||||
let commit_thread = thread::spawn(move || {
|
let commit_thread = thread::spawn(move || {
|
||||||
// This mirrors Worker::append_entry ordering: clear in-flight first,
|
// This mirrors Worker::append_entry ordering: clear in-flight first,
|
||||||
// then publish the finalized AssistantItem. AssistantItem entries
|
// then publish the finalized AssistantItem. Identity-aware clients
|
||||||
// are mirror-only and are not delivered as live entry events.
|
// reconcile this durable commit with the preceding streamed block.
|
||||||
in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || {
|
in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || {
|
||||||
sink_for_commit.publish(assistant_entry);
|
sink_for_commit.publish(assistant_entry);
|
||||||
});
|
});
|
||||||
@@ -639,7 +639,10 @@ mod tests {
|
|||||||
in_flight_snapshot.blocks.as_slice(),
|
in_flight_snapshot.blocks.as_slice(),
|
||||||
[InFlightBlock::Text { text, finished: true }] if text == "done"
|
[InFlightBlock::Text { text, finished: true }] if text == "done"
|
||||||
));
|
));
|
||||||
assert!(entry_rx.try_recv().is_err());
|
assert!(matches!(
|
||||||
|
entry_rx.try_recv(),
|
||||||
|
Ok(LogEntry::AnnotatedAssistantItem { .. })
|
||||||
|
));
|
||||||
let post_commit_guard = in_flight.snapshot_guard();
|
let post_commit_guard = in_flight.snapshot_guard();
|
||||||
assert!(snapshot_from_guard(&post_commit_guard).is_empty());
|
assert!(snapshot_from_guard(&post_commit_guard).is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,41 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
|||||||
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
|
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
|
||||||
Some(Event::SegmentRotated { session })
|
Some(Event::SegmentRotated { session })
|
||||||
}
|
}
|
||||||
LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }),
|
LogEntry::AnnotatedUserInput {
|
||||||
|
ts,
|
||||||
|
segments,
|
||||||
|
history,
|
||||||
|
extensions,
|
||||||
|
} => {
|
||||||
|
let projected = session_store::public_snapshot::project_current_session_snapshot(&[
|
||||||
|
LogEntry::AnnotatedUserInput {
|
||||||
|
ts,
|
||||||
|
segments: segments.clone(),
|
||||||
|
history,
|
||||||
|
extensions,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let entry_id = projected
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| {
|
||||||
|
matches!(
|
||||||
|
entry.data,
|
||||||
|
protocol::SessionSnapshotEntryData::UserInput { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|entry| entry.entry_id.clone());
|
||||||
|
Some(Event::UserMessage { entry_id, segments })
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
@ (LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. }) => {
|
||||||
|
let mut projected =
|
||||||
|
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
|
||||||
|
projected
|
||||||
|
.entries
|
||||||
|
.pop()
|
||||||
|
.map(|entry| Event::SessionEntryCommitted { entry })
|
||||||
|
}
|
||||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||||
Some(Event::SystemItem { item: value })
|
Some(Event::SystemItem { item: value })
|
||||||
@@ -94,7 +128,9 @@ mod tests {
|
|||||||
.expect("UserInput must be live-relevant");
|
.expect("UserInput must be live-relevant");
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::UserMessage { segments: echoed } => assert_eq!(echoed, segments),
|
Event::UserMessage {
|
||||||
|
segments: echoed, ..
|
||||||
|
} => assert_eq!(echoed, segments),
|
||||||
other => panic!("expected UserMessage, got {other:?}"),
|
other => panic!("expected UserMessage, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,14 +92,11 @@ impl SegmentLogSink {
|
|||||||
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
|
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
|
||||||
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
|
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
|
||||||
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
|
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
|
||||||
|
/// - assistant/tool commits → `Event::SessionEntryCommitted`.
|
||||||
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
|
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
|
||||||
/// Everything else (AssistantItem, ToolResult, TurnEnd,
|
/// Operational checkpoints remain mirror-only. Assistant/tool streaming
|
||||||
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
|
/// events are still the in-flight authority; the committed-entry event adds
|
||||||
/// ConfigChanged) is reflected in the mirror so reconnect snapshots stay accurate,
|
/// the durable stable identity needed to reconcile retained history.
|
||||||
/// but is not sent live — the streaming events (TextDelta /
|
|
||||||
/// ToolCallStart / ToolResult / TurnEnd / etc.) already provide
|
|
||||||
/// that data, and re-broadcasting it as a typed entry would just
|
|
||||||
/// double-render every block on the client side.
|
|
||||||
pub fn publish(&self, entry: LogEntry) {
|
pub fn publish(&self, entry: LogEntry) {
|
||||||
let mut mirror = self
|
let mut mirror = self
|
||||||
.inner
|
.inner
|
||||||
@@ -122,6 +119,8 @@ impl SegmentLogSink {
|
|||||||
entry,
|
entry,
|
||||||
LogEntry::AnnotatedSegmentStart { .. }
|
LogEntry::AnnotatedSegmentStart { .. }
|
||||||
| LogEntry::AnnotatedUserInput { .. }
|
| LogEntry::AnnotatedUserInput { .. }
|
||||||
|
| LogEntry::AnnotatedAssistantItem { .. }
|
||||||
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. }
|
| LogEntry::AnnotatedSystemItem { .. }
|
||||||
| LogEntry::Invoke { .. }
|
| LogEntry::Invoke { .. }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1188,6 +1188,19 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
_workspace_id: &str,
|
||||||
|
_worker_ref: &EmbeddedWorkerRef,
|
||||||
|
_cursor: Option<String>,
|
||||||
|
_limit: Option<usize>,
|
||||||
|
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
|
||||||
|
Ok(runtime_api::WorkerSessionHistoryAvailability::Unavailable {
|
||||||
|
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
|
||||||
|
message: "session history paging is not supported by this Runtime".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
_worker_id: &str,
|
_worker_id: &str,
|
||||||
@@ -2017,6 +2030,31 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<runtime_api::WorkerSessionHistoryAvailability, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
|
||||||
|
validate_backend_identifier("worker_id", &worker.worker_id)?;
|
||||||
|
let runtime = self.runtime(&worker.runtime_id)?;
|
||||||
|
let worker_ref =
|
||||||
|
EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(&worker.worker_id).ok_or_else(
|
||||||
|
|| RuntimeRegistryError::UnknownWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
},
|
||||||
|
)?);
|
||||||
|
runtime
|
||||||
|
.worker_session_history(workspace_id, &worker_ref, cursor, limit)
|
||||||
|
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
code: "worker_session_history_failed".to_string(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn observation_source(
|
pub fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
@@ -2909,6 +2947,19 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
|
||||||
|
let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
|
||||||
|
self.runtime
|
||||||
|
.worker_session_history_scoped(&scope, worker_ref, cursor, limit)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -4924,6 +4975,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| diagnostic.message)
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
|
||||||
|
let worker_id = worker_ref.worker_id.to_string();
|
||||||
|
let request = runtime_api::WorkerSessionHistoryRequest {
|
||||||
|
workspace_id: workspace_id.to_string(),
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
self.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.worker_session_history(worker_id, request).await },
|
||||||
|
)
|
||||||
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
|
|||||||
@@ -4584,6 +4584,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::skill_get(service.clone()))
|
.merge(server_api::server_api_axum::skill_get(service.clone()))
|
||||||
.merge(server_api::server_api_axum::skill_activate(service.clone()))
|
.merge(server_api::server_api_axum::skill_activate(service.clone()))
|
||||||
.merge(server_api::server_api_axum::worker_session(service.clone()))
|
.merge(server_api::server_api_axum::worker_session(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::worker_session_history(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
.merge(server_api::server_api_axum::repository_list(
|
.merge(server_api::server_api_axum::repository_list(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
@@ -6248,6 +6251,50 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn worker_session_history(
|
||||||
|
&self,
|
||||||
|
workspace_id: String,
|
||||||
|
runtime_id: String,
|
||||||
|
worker_id: String,
|
||||||
|
query: server_api::WorkspaceWorkerSessionHistoryQuery,
|
||||||
|
) -> std::result::Result<
|
||||||
|
server_api::WorkspaceWorkerSessionHistoryResponse,
|
||||||
|
server_api::ServerApiError,
|
||||||
|
> {
|
||||||
|
let api = self.workspace_api().map_err(|_| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::NOT_FOUND.as_u16(),
|
||||||
|
"workspace_not_found",
|
||||||
|
"Workspace was not found",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if workspace_id != api.config.workspace_id {
|
||||||
|
return Err(runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::NOT_FOUND.as_u16(),
|
||||||
|
"workspace_not_found",
|
||||||
|
"Workspace was not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone());
|
||||||
|
let observation = api
|
||||||
|
.runtime
|
||||||
|
.worker_session_history(&workspace_id, &worker, query.cursor, query.limit)
|
||||||
|
.map_err(|_error| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::BAD_GATEWAY.as_u16(),
|
||||||
|
"worker_session_history_failed",
|
||||||
|
"Worker Session history request failed",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(server_api::WorkspaceWorkerSessionHistoryResponse {
|
||||||
|
subject: server_api::WorkspaceWorkerSubject::RuntimeWorker {
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
},
|
||||||
|
observation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn workspace_worker_list_alias(
|
async fn workspace_worker_list_alias(
|
||||||
&self,
|
&self,
|
||||||
) -> std::result::Result<server_api::WorkerListResponse, server_api::RepositoryApiError> {
|
) -> std::result::Result<server_api::WorkerListResponse, server_api::RepositoryApiError> {
|
||||||
|
|||||||
@@ -14020,7 +14020,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052"
|
"x-yoi-source-digest": "sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -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,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
|
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -149,6 +149,14 @@ entry_id: string,
|
|||||||
*/
|
*/
|
||||||
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
|
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
|
||||||
|
|
||||||
|
export type SessionConversationTurn = { turn_id: string, entries: Array<SessionSnapshotEntry>, };
|
||||||
|
|
||||||
|
export type SessionHistoryPage = { session_id: string,
|
||||||
|
/**
|
||||||
|
* Stable identity of the adopted active lineage used to fence stale pages.
|
||||||
|
*/
|
||||||
|
lineage_id: string, turns: Array<SessionConversationTurn>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
|
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
|
||||||
|
|
||||||
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
|
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
|
||||||
@@ -296,7 +304,11 @@ export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "req
|
|||||||
|
|
||||||
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
|
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
|
||||||
|
|
||||||
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
|
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
|
||||||
|
/**
|
||||||
|
* Stable durable history identity. Older mixed-version producers omit it.
|
||||||
|
*/
|
||||||
|
entry_id?: string | null, segments: Array<Segment>, } } | { "event": "session_entry_committed", "data": { entry: SessionSnapshotEntry, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
|
||||||
/**
|
/**
|
||||||
* The attempt that just failed. 1 origin.
|
* The attempt that just failed. 1 origin.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
|
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
|
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
|
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
|
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TurnNavigationItem } from "./turn-navigation";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: TurnNavigationItem[];
|
||||||
|
hasMore: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
onTurnClick: (item: TurnNavigationItem) => void;
|
||||||
|
onTopEdgeChange: (atTop: boolean) => void;
|
||||||
|
historyAvailable?: boolean;
|
||||||
|
element?: HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onLoadMore,
|
||||||
|
onTurnClick,
|
||||||
|
onTopEdgeChange,
|
||||||
|
historyAvailable = true,
|
||||||
|
element = $bindable(null),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleScroll(event: Event) {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
onTopEdgeChange(target.scrollTop <= 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="turn-navigation" aria-label="Conversation turns">
|
||||||
|
<div class="turn-list" bind:this={element} onscroll={handleScroll}>
|
||||||
|
<div class="history-boundary" class:complete={!hasMore && !error && !loading}>
|
||||||
|
{#if !historyAvailable}
|
||||||
|
<span>Earlier history is unavailable for this view</span>
|
||||||
|
{:else if loading}
|
||||||
|
<button type="button" disabled>Loading earlier conversation…</button>
|
||||||
|
{:else if error}
|
||||||
|
<button type="button" onclick={onLoadMore}>Retry earlier conversation</button>
|
||||||
|
<span title={error}>History unavailable</span>
|
||||||
|
{:else if hasMore}
|
||||||
|
<button type="button" onclick={onLoadMore}>Earlier conversation available</button>
|
||||||
|
{:else}
|
||||||
|
<span>Start of conversation</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each items as item (item.turnId)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="turn"
|
||||||
|
data-turn-id={item.turnId}
|
||||||
|
aria-label={`Jump to conversation: ${item.user}`}
|
||||||
|
onclick={() => onTurnClick(item)}
|
||||||
|
>
|
||||||
|
<span class="bar" aria-hidden="true"></span>
|
||||||
|
<span class="preview">
|
||||||
|
<span class="user-preview">{item.user}</span>
|
||||||
|
{#each item.assistant as line}
|
||||||
|
<span class="assistant-preview">{line}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.turn-navigation {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-list {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: var(--space-2) var(--space-2) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-boundary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
justify-items: center;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-boundary button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tui-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-boundary button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-boundary.complete::before {
|
||||||
|
content: "";
|
||||||
|
width: 2rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1.6rem minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.42rem 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 1.1rem;
|
||||||
|
border-top: 3px solid var(--tui-blue);
|
||||||
|
border-radius: 999px;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-preview,
|
||||||
|
.assistant-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-preview {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-preview {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn:focus-visible,
|
||||||
|
.turn:hover {
|
||||||
|
background: color-mix(in srgb, var(--tui-blue) 9%, transparent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type {
|
||||||
|
SessionConversationTurn,
|
||||||
|
SessionHistoryPage,
|
||||||
|
SessionSnapshotEntry,
|
||||||
|
} from "$lib/generated/protocol";
|
||||||
|
import {
|
||||||
|
applyConsoleHistoryPage,
|
||||||
|
beginConsoleHistoryRequest,
|
||||||
|
type ConsoleHistoryState,
|
||||||
|
conversationTurnPreviews,
|
||||||
|
emptyConsoleHistoryState,
|
||||||
|
historyEntries,
|
||||||
|
setConsoleHistoryTopEdge,
|
||||||
|
shouldLoadHistoryAtTop,
|
||||||
|
} from "./history";
|
||||||
|
|
||||||
|
declare const Deno: { test(name: string, fn: () => void): void };
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(
|
||||||
|
id: string,
|
||||||
|
role: "user" | "assistant",
|
||||||
|
text: string,
|
||||||
|
): SessionSnapshotEntry {
|
||||||
|
return {
|
||||||
|
entry_id: id,
|
||||||
|
timestamp: 1,
|
||||||
|
provenance: role === "user" ? "human_input" : "model_output",
|
||||||
|
kind: "message",
|
||||||
|
role,
|
||||||
|
content: [{ kind: "text", text }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function turn(index: number): SessionConversationTurn {
|
||||||
|
return {
|
||||||
|
turn_id: `u-${index}`,
|
||||||
|
entries: [
|
||||||
|
message(`u-${index}`, "user", `question ${index}\nignored`),
|
||||||
|
message(`a-progress-${index}`, "assistant", `working ${index}`),
|
||||||
|
message(
|
||||||
|
`a-final-${index}`,
|
||||||
|
"assistant",
|
||||||
|
`done ${index}\ndetail a\ndetail b\ndetail c`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(
|
||||||
|
turns: SessionConversationTurn[],
|
||||||
|
cursor: string | null,
|
||||||
|
): SessionHistoryPage {
|
||||||
|
return {
|
||||||
|
session_id: "session-a",
|
||||||
|
lineage_id: "lineage-a",
|
||||||
|
turns,
|
||||||
|
next_cursor: cursor,
|
||||||
|
has_more: cursor !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("history prepends 12 turns as 5 then 5 then 2 without duplicates", () => {
|
||||||
|
let state = emptyConsoleHistoryState();
|
||||||
|
state = beginConsoleHistoryRequest(state, null)!;
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
state = beginConsoleHistoryRequest(state, "cursor-8")!;
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
|
||||||
|
"cursor-8",
|
||||||
|
);
|
||||||
|
state = beginConsoleHistoryRequest(state, "cursor-3")!;
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([1, 2].map(turn), null),
|
||||||
|
"cursor-3",
|
||||||
|
);
|
||||||
|
assert(state.turns.length === 12, "all real turns must be retained");
|
||||||
|
assert(state.turns[0].turn_id === "u-1", "pages must remain chronological");
|
||||||
|
assert(
|
||||||
|
!state.hasMore && state.cursor === null,
|
||||||
|
"final short page must end history",
|
||||||
|
);
|
||||||
|
|
||||||
|
state = { ...state, requestedCursor: "__initial__", status: "loading" };
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
state.turns.length === 12,
|
||||||
|
"refresh must dedupe stable turn identities",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
historyEntries(state).length === 36,
|
||||||
|
"entries must dedupe by stable identity",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("refresh replaces overlapping latest turns while retaining loaded earlier turns", () => {
|
||||||
|
let state = emptyConsoleHistoryState();
|
||||||
|
state = beginConsoleHistoryRequest(state, null)!;
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
state = beginConsoleHistoryRequest(state, "cursor-8")!;
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
|
||||||
|
"cursor-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = turn(12);
|
||||||
|
updated.entries.push(message("a-later-12", "assistant", "actually finished"));
|
||||||
|
state = beginConsoleHistoryRequest(state, null)!;
|
||||||
|
state = applyConsoleHistoryPage(
|
||||||
|
state,
|
||||||
|
page([8, 9, 10, 11].map(turn).concat(updated), "stale-cursor-is-ignored"),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
state.turns.length === 10,
|
||||||
|
"refresh must not discard loaded earlier turns",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
state.turns.at(-1)?.entries.at(-1)?.entry_id === "a-later-12",
|
||||||
|
"refresh must replace an overlapping turn with its latest committed entries",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
state.cursor === "cursor-3",
|
||||||
|
"refresh must retain the oldest paging boundary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
|
||||||
|
const preview = conversationTurnPreviews([turn(4)])[0];
|
||||||
|
assert(preview.user === "question 4", "user preview must use its first line");
|
||||||
|
assert(
|
||||||
|
preview.assistant.join("|") === "done 4|detail a|detail b",
|
||||||
|
"preview must choose the final assistant and cap it at three lines",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("top sentinel requests only once until it leaves the edge", () => {
|
||||||
|
let state: ConsoleHistoryState = {
|
||||||
|
...emptyConsoleHistoryState(),
|
||||||
|
status: "ready",
|
||||||
|
cursor: "older",
|
||||||
|
hasMore: true,
|
||||||
|
};
|
||||||
|
assert(
|
||||||
|
shouldLoadHistoryAtTop(state, true),
|
||||||
|
"armed top edge should request a page",
|
||||||
|
);
|
||||||
|
state = beginConsoleHistoryRequest(state, "older")!;
|
||||||
|
assert(
|
||||||
|
!shouldLoadHistoryAtTop(state, true),
|
||||||
|
"in-flight page must suppress duplicates",
|
||||||
|
);
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
status: "ready",
|
||||||
|
requestedCursor: null,
|
||||||
|
};
|
||||||
|
assert(
|
||||||
|
!shouldLoadHistoryAtTop(state, true),
|
||||||
|
"stationary sentinel must not drain history",
|
||||||
|
);
|
||||||
|
state = setConsoleHistoryTopEdge(state, false);
|
||||||
|
assert(
|
||||||
|
shouldLoadHistoryAtTop(state, true),
|
||||||
|
"leaving the top must rearm one request",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type {
|
||||||
|
SessionConversationTurn,
|
||||||
|
SessionHistoryPage,
|
||||||
|
SessionSnapshotEntry,
|
||||||
|
} from "$lib/generated/protocol";
|
||||||
|
|
||||||
|
export const CONSOLE_HISTORY_PAGE_TURNS = 5;
|
||||||
|
|
||||||
|
export type ConsoleHistoryStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export type ConsoleHistoryState = {
|
||||||
|
sessionId: string | null;
|
||||||
|
lineageId: string | null;
|
||||||
|
turns: SessionConversationTurn[];
|
||||||
|
cursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
status: ConsoleHistoryStatus;
|
||||||
|
error: string | null;
|
||||||
|
/** Cursor currently in flight; prevents duplicate sentinel requests. */
|
||||||
|
requestedCursor: string | null;
|
||||||
|
/** Rearmed only after leaving the top edge or an explicit user action. */
|
||||||
|
topEdgeArmed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsoleTurnPreview = {
|
||||||
|
turnId: string;
|
||||||
|
lineId: string;
|
||||||
|
user: string;
|
||||||
|
assistant: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function emptyConsoleHistoryState(): ConsoleHistoryState {
|
||||||
|
return {
|
||||||
|
sessionId: null,
|
||||||
|
lineageId: null,
|
||||||
|
turns: [],
|
||||||
|
cursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
status: "idle",
|
||||||
|
error: null,
|
||||||
|
requestedCursor: null,
|
||||||
|
topEdgeArmed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginConsoleHistoryRequest(
|
||||||
|
state: ConsoleHistoryState,
|
||||||
|
cursor: string | null,
|
||||||
|
): ConsoleHistoryState | null {
|
||||||
|
if (state.status === "loading" || state.requestedCursor !== null) return null;
|
||||||
|
if (cursor !== null && (!state.hasMore || cursor !== state.cursor)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: "loading",
|
||||||
|
error: null,
|
||||||
|
requestedCursor: cursor ?? "__initial__",
|
||||||
|
topEdgeArmed: cursor === null ? state.topEdgeArmed : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyConsoleHistoryPage(
|
||||||
|
state: ConsoleHistoryState,
|
||||||
|
page: SessionHistoryPage,
|
||||||
|
requestedCursor: string | null,
|
||||||
|
): ConsoleHistoryState {
|
||||||
|
const requestKey = requestedCursor ?? "__initial__";
|
||||||
|
if (state.requestedCursor !== requestKey) return state;
|
||||||
|
|
||||||
|
const initial = requestedCursor === null;
|
||||||
|
if (
|
||||||
|
!initial &&
|
||||||
|
(state.sessionId !== page.session_id || state.lineageId !== page.lineage_id)
|
||||||
|
) {
|
||||||
|
return failConsoleHistoryRequest(
|
||||||
|
state,
|
||||||
|
"The conversation changed while earlier history was loading. Retry from the current view.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming = page.turns.filter((turn) => turn.entries.length > 0);
|
||||||
|
const refreshingSameSession = initial &&
|
||||||
|
state.sessionId === page.session_id &&
|
||||||
|
state.turns.length > 0;
|
||||||
|
const turns = initial
|
||||||
|
? refreshingSameSession
|
||||||
|
? mergeRefreshedTurns(state.turns, incoming)
|
||||||
|
: dedupeTurns(incoming)
|
||||||
|
: dedupeTurns([...incoming, ...state.turns]);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sessionId: page.session_id,
|
||||||
|
lineageId: page.lineage_id,
|
||||||
|
turns,
|
||||||
|
cursor: refreshingSameSession ? state.cursor : (page.next_cursor ?? null),
|
||||||
|
hasMore: refreshingSameSession ? state.hasMore : page.has_more,
|
||||||
|
status: "ready",
|
||||||
|
error: null,
|
||||||
|
requestedCursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failConsoleHistoryRequest(
|
||||||
|
state: ConsoleHistoryState,
|
||||||
|
message: string,
|
||||||
|
): ConsoleHistoryState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: "error",
|
||||||
|
error: message.slice(0, 512),
|
||||||
|
requestedCursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConsoleHistoryTopEdge(
|
||||||
|
state: ConsoleHistoryState,
|
||||||
|
atTop: boolean,
|
||||||
|
): ConsoleHistoryState {
|
||||||
|
if (!atTop && !state.topEdgeArmed) return { ...state, topEdgeArmed: true };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldLoadHistoryAtTop(
|
||||||
|
state: ConsoleHistoryState,
|
||||||
|
atTop: boolean,
|
||||||
|
): boolean {
|
||||||
|
return atTop && state.topEdgeArmed && state.hasMore &&
|
||||||
|
state.status !== "loading" && state.requestedCursor === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function historyEntries(
|
||||||
|
state: ConsoleHistoryState,
|
||||||
|
): SessionSnapshotEntry[] {
|
||||||
|
return state.turns.flatMap((turn) => turn.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conversationTurnPreviews(
|
||||||
|
turns: readonly SessionConversationTurn[],
|
||||||
|
): ConsoleTurnPreview[] {
|
||||||
|
return turns.map((turn) => {
|
||||||
|
const userEntry = turn.entries.find(isUserEntry);
|
||||||
|
const assistants = turn.entries
|
||||||
|
.filter(isAssistantEntry)
|
||||||
|
.map(entryText)
|
||||||
|
.filter((text) => text.trim().length > 0);
|
||||||
|
const assistant = (assistants.at(-1) ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3);
|
||||||
|
return {
|
||||||
|
turnId: turn.turn_id,
|
||||||
|
lineId: userEntry?.entry_id ?? turn.turn_id,
|
||||||
|
user: firstNonEmptyLine(userEntry ? entryText(userEntry) : ""),
|
||||||
|
assistant,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRefreshedTurns(
|
||||||
|
retained: readonly SessionConversationTurn[],
|
||||||
|
refreshed: readonly SessionConversationTurn[],
|
||||||
|
): SessionConversationTurn[] {
|
||||||
|
const refreshedIds = new Set(refreshed.map((turn) => turn.turn_id));
|
||||||
|
return dedupeTurns([
|
||||||
|
...retained.filter((turn) => !refreshedIds.has(turn.turn_id)),
|
||||||
|
...refreshed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeTurns(
|
||||||
|
turns: readonly SessionConversationTurn[],
|
||||||
|
): SessionConversationTurn[] {
|
||||||
|
const seenTurns = new Set<string>();
|
||||||
|
const seenEntries = new Set<string>();
|
||||||
|
const result: SessionConversationTurn[] = [];
|
||||||
|
for (const turn of turns) {
|
||||||
|
if (seenTurns.has(turn.turn_id)) continue;
|
||||||
|
const entries = turn.entries.filter((entry) => {
|
||||||
|
if (seenEntries.has(entry.entry_id)) return false;
|
||||||
|
seenEntries.add(entry.entry_id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (entries.length === 0) continue;
|
||||||
|
seenTurns.add(turn.turn_id);
|
||||||
|
result.push({ ...turn, entries });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserEntry(entry: SessionSnapshotEntry): boolean {
|
||||||
|
return entry.kind === "user_input" ||
|
||||||
|
(entry.kind === "message" && entry.role === "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistantEntry(entry: SessionSnapshotEntry): boolean {
|
||||||
|
return entry.kind === "message" && entry.role === "assistant";
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryText(entry: SessionSnapshotEntry): string {
|
||||||
|
switch (entry.kind) {
|
||||||
|
case "user_input":
|
||||||
|
return entry.segments.map((segment) => {
|
||||||
|
if (segment.kind === "text" || segment.kind === "paste") {
|
||||||
|
return segment.content;
|
||||||
|
}
|
||||||
|
if (segment.kind === "file_ref") return `@file ${segment.path}`;
|
||||||
|
if (segment.kind === "uploaded_file") {
|
||||||
|
return `[Attachment: ${segment.file.file_name}]`;
|
||||||
|
}
|
||||||
|
if (segment.kind === "paste_artifact") {
|
||||||
|
return `[Large paste ${segment.artifact.artifact_id}]`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}).filter(Boolean).join("\n");
|
||||||
|
case "message":
|
||||||
|
return entry.content.map((part) =>
|
||||||
|
part.kind === "text" ? part.text : part.refusal
|
||||||
|
).join("\n");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyLine(value: string): string {
|
||||||
|
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "—";
|
||||||
|
}
|
||||||
@@ -2970,3 +2970,113 @@ Deno.test("new invoke resets stats before the next RunEnd", () => {
|
|||||||
|
|
||||||
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
|
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("committed assistant identity reconciles the live block without text matching", () => {
|
||||||
|
const lines = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "user-live",
|
||||||
|
event: {
|
||||||
|
event: "user_message",
|
||||||
|
data: {
|
||||||
|
entry_id: "user-entry",
|
||||||
|
segments: [{ kind: "text", content: "question" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "assistant-live",
|
||||||
|
event: { event: "text_done", data: { text: "final answer" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "assistant-commit",
|
||||||
|
event: {
|
||||||
|
event: "session_entry_committed",
|
||||||
|
data: {
|
||||||
|
entry: {
|
||||||
|
entry_id: "assistant-entry",
|
||||||
|
timestamp: 7,
|
||||||
|
provenance: "model_output",
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: "final answer" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).lines;
|
||||||
|
assertEquals(lines.filter((line) => line.kind === "assistant").length, 1);
|
||||||
|
assertEquals(
|
||||||
|
lines.find((line) => line.kind === "assistant")?.entryId,
|
||||||
|
"assistant-entry",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
lines.find((line) => line.kind === "user")?.entryId,
|
||||||
|
"user-entry",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("committed tool identities reconcile one live call block", () => {
|
||||||
|
const lines = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "tool-live",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: { id: "call-1", name: "Read", arguments: '{"file_path":"a"}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "tool-call-commit",
|
||||||
|
event: {
|
||||||
|
event: "session_entry_committed",
|
||||||
|
data: {
|
||||||
|
entry: {
|
||||||
|
entry_id: "tool-call-entry",
|
||||||
|
timestamp: 8,
|
||||||
|
provenance: "model_output",
|
||||||
|
kind: "tool_call",
|
||||||
|
call_id: "call-1",
|
||||||
|
name: "Read",
|
||||||
|
arguments: '{"file_path":"a"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "tool-result-live",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "call-1",
|
||||||
|
summary: "read",
|
||||||
|
output: "contents",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "tool-result-commit",
|
||||||
|
event: {
|
||||||
|
event: "session_entry_committed",
|
||||||
|
data: {
|
||||||
|
entry: {
|
||||||
|
entry_id: "tool-result-entry",
|
||||||
|
timestamp: 9,
|
||||||
|
provenance: "tool_output",
|
||||||
|
kind: "tool_result",
|
||||||
|
call_id: "call-1",
|
||||||
|
summary: "read",
|
||||||
|
content: "contents",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).lines;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
lines.filter((line) => line.toolCall?.id === "call-1").length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assertEquals(lines[0].entryId, "tool-result-entry");
|
||||||
|
assertEquals(lines[0].toolCall?.state, "done");
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
InternalWorkerRef,
|
InternalWorkerRef,
|
||||||
InternalWorkerSnapshot,
|
InternalWorkerSnapshot,
|
||||||
Segment,
|
Segment,
|
||||||
|
SessionSnapshotEntry,
|
||||||
WorkerState,
|
WorkerState,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
@@ -90,6 +91,8 @@ export type ConsoleLine = {
|
|||||||
compaction?: ConsoleCompaction;
|
compaction?: ConsoleCompaction;
|
||||||
diff?: ConsoleDiffLine[];
|
diff?: ConsoleDiffLine[];
|
||||||
eventId?: string | null;
|
eventId?: string | null;
|
||||||
|
/** Stable durable identity when this line originates from a committed entry. */
|
||||||
|
entryId?: string;
|
||||||
source: "event";
|
source: "event";
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
@@ -279,6 +282,30 @@ export function createConsoleProjector() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeCommittedHistoryLines(
|
||||||
|
history: ConsoleLine[],
|
||||||
|
current: ConsoleLine[],
|
||||||
|
): ConsoleLine[] {
|
||||||
|
const committed = new Set(
|
||||||
|
history.map((line) => line.entryId).filter((id): id is string =>
|
||||||
|
Boolean(id)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...history,
|
||||||
|
...current.filter((line) => !line.entryId || !committed.has(line.entryId)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectSessionHistoryEntries(
|
||||||
|
entries: SessionSnapshotEntry[],
|
||||||
|
cwd: string | null,
|
||||||
|
): ConsoleLine[] {
|
||||||
|
return projectVisibleConsole(
|
||||||
|
snapshotProjectionFromSession("history", { entries }, cwd),
|
||||||
|
).lines;
|
||||||
|
}
|
||||||
|
|
||||||
function projectVisibleConsole(
|
function projectVisibleConsole(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
): ConsoleProjection {
|
): ConsoleProjection {
|
||||||
@@ -846,14 +873,19 @@ export function applyProtocolEvent(
|
|||||||
|
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case "user_message":
|
case "user_message":
|
||||||
next.lines.push(
|
next.lines.push({
|
||||||
line(
|
...line(
|
||||||
envelope.eventId,
|
envelope.eventId,
|
||||||
"user",
|
"user",
|
||||||
"User",
|
"User",
|
||||||
segmentsToText(event.data.segments),
|
segmentsToText(event.data.segments),
|
||||||
),
|
),
|
||||||
);
|
entryId: event.data.entry_id ?? undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "session_entry_committed":
|
||||||
|
reconcileCommittedSessionEntry(next, event.data.entry);
|
||||||
|
applySessionEntry(next, envelope.eventId, event.data.entry);
|
||||||
break;
|
break;
|
||||||
case "system_item":
|
case "system_item":
|
||||||
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
||||||
@@ -1540,6 +1572,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
||||||
detail: calls.map(readDetail).join("\n\n"),
|
detail: calls.map(readDetail).join("\n\n"),
|
||||||
eventId: group.at(-1)?.eventId,
|
eventId: group.at(-1)?.eventId,
|
||||||
|
entryId: group.at(-1)?.entryId,
|
||||||
source: "event",
|
source: "event",
|
||||||
streaming: inProgress,
|
streaming: inProgress,
|
||||||
error: hasError,
|
error: hasError,
|
||||||
@@ -2004,6 +2037,22 @@ function snapshotProjectionFromSession(
|
|||||||
return projection;
|
return projection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reconcileCommittedSessionEntry(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
entry: SessionSnapshotEntry,
|
||||||
|
): void {
|
||||||
|
if (entry.kind !== "message" || entry.role !== "assistant") return;
|
||||||
|
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const line = projection.lines[index];
|
||||||
|
if (line.entryId) continue;
|
||||||
|
if (line.kind === "assistant" || line.kind === "in_flight") {
|
||||||
|
projection.lines.splice(index, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (line.kind === "user") return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applySessionEntry(
|
function applySessionEntry(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
fallbackEventId: string,
|
fallbackEventId: string,
|
||||||
@@ -2011,6 +2060,7 @@ function applySessionEntry(
|
|||||||
): void {
|
): void {
|
||||||
if (!isRecord(value)) return;
|
if (!isRecord(value)) return;
|
||||||
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
|
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
|
||||||
|
const lineCountBefore = projection.lines.length;
|
||||||
switch (stringField(value, "kind")) {
|
switch (stringField(value, "kind")) {
|
||||||
case "user_input":
|
case "user_input":
|
||||||
projection.lines.push(
|
projection.lines.push(
|
||||||
@@ -2068,6 +2118,18 @@ function applySessionEntry(
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const entryId = stringField(value, "entry_id");
|
||||||
|
if (entryId) {
|
||||||
|
for (
|
||||||
|
let index = Math.max(0, lineCountBefore - 1);
|
||||||
|
index < projection.lines.length;
|
||||||
|
index += 1
|
||||||
|
) {
|
||||||
|
if (projection.lines[index].eventId === eventId) {
|
||||||
|
projection.lines[index].entryId = entryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLoggedUserInput(
|
function applyLoggedUserInput(
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { SessionConversationTurn } from "$lib/generated/protocol";
|
||||||
|
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
|
||||||
|
|
||||||
|
export type TurnNavigationItem = ConsoleTurnPreview;
|
||||||
|
|
||||||
|
export function buildTurnNavigationItems(
|
||||||
|
turns: readonly SessionConversationTurn[],
|
||||||
|
): TurnNavigationItem[] {
|
||||||
|
return conversationTurnPreviews(turns);
|
||||||
|
}
|
||||||
@@ -720,7 +720,7 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => {
|
Deno.test("Worker Console exposes paged conversation navigation beside the scroll body", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
const consolePage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||||
@@ -730,28 +730,24 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
|
|||||||
const consoleLine = await Deno.readTextFile(
|
const consoleLine = await Deno.readTextFile(
|
||||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const consoleTimeline = await Deno.readTextFile(
|
const turnNavigation = await Deno.readTextFile(
|
||||||
new URL("./ConsoleTimeline.svelte", import.meta.url),
|
new URL("./ConsoleTurnNavigation.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
consoleTimeline.includes('class="console-timeline"') &&
|
turnNavigation.includes('class="turn-navigation"') &&
|
||||||
consolePage.includes("timelineMarks") &&
|
turnNavigation.includes('class="turn-list"') &&
|
||||||
consolePage.includes("jumpToTimelineMark") &&
|
turnNavigation.includes("data-turn-id={item.turnId}") &&
|
||||||
|
turnNavigation.includes('class="user-preview"') &&
|
||||||
|
turnNavigation.includes('class="assistant-preview"') &&
|
||||||
|
turnNavigation.includes("onTopEdgeChange(target.scrollTop <= 1)") &&
|
||||||
|
consolePage.includes("<ConsoleTurnNavigation") &&
|
||||||
|
consolePage.includes("items={turnNavigationItems}") &&
|
||||||
|
consolePage.includes("onTopEdgeChange={handleHistoryTopEdge}") &&
|
||||||
|
consolePage.includes("onTurnClick={jumpToConversationTurn}") &&
|
||||||
|
consolePage.includes('class="conversation-history-boundary"') &&
|
||||||
consoleLine.includes("data-console-line-id={item.id}") &&
|
consoleLine.includes("data-console-line-id={item.id}") &&
|
||||||
consolePage.includes("class:timeline-open={timelineOpen}") &&
|
!consolePage.includes("<ConsoleTimeline"),
|
||||||
consolePage.includes('class="timeline-fold"') &&
|
"Worker Console should expose bounded turn navigation with shared paging and transcript jump targets",
|
||||||
consolePage.includes("expanded={timelineOpen}") &&
|
|
||||||
!consolePage.includes("{#if timelineOpen}") &&
|
|
||||||
consolePage.includes("handleTimelineRailPointerDown") &&
|
|
||||||
consolePage.includes("projectTimelineAxisPosition") &&
|
|
||||||
consolePage.includes("scrollbar-width: none") &&
|
|
||||||
consoleTimeline.includes("onpointerdown={onRailPointerDown}") &&
|
|
||||||
consoleTimeline.includes("expanded ? 'expanded' : 'folded'") &&
|
|
||||||
consoleTimeline.includes(".timeline-mark.expanded .timeline-card") &&
|
|
||||||
!consoleTimeline.includes("{#if expanded}") &&
|
|
||||||
consoleTimeline.includes(".timeline-thumb") &&
|
|
||||||
consoleTimeline.includes(".timeline-card"),
|
|
||||||
"Worker Console should expose a foldable timeline with scroll and line jump markers",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+310
-65
@@ -4,7 +4,7 @@
|
|||||||
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
||||||
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
|
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
|
||||||
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||||
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
import ConsoleTurnNavigation from "$lib/workspace/console/ConsoleTurnNavigation.svelte";
|
||||||
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
|
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
|
||||||
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
|
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,9 @@
|
|||||||
consoleWorkerViews,
|
consoleWorkerViews,
|
||||||
createConsoleProjector,
|
createConsoleProjector,
|
||||||
isConsoleProjectionEvent,
|
isConsoleProjectionEvent,
|
||||||
|
mergeCommittedHistoryLines,
|
||||||
projectConsoleLines,
|
projectConsoleLines,
|
||||||
|
projectSessionHistoryEntries,
|
||||||
resolveConsoleViewScrollTop,
|
resolveConsoleViewScrollTop,
|
||||||
resolveConsoleWorkerView,
|
resolveConsoleWorkerView,
|
||||||
selectConsoleTimelineLines,
|
selectConsoleTimelineLines,
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
PendingSubmissionsSnapshot,
|
PendingSubmissionsSnapshot,
|
||||||
RewindTarget,
|
RewindTarget,
|
||||||
Segment,
|
Segment,
|
||||||
|
SessionHistoryPage,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
import {
|
import {
|
||||||
MAX_FILES_PER_SUBMISSION,
|
MAX_FILES_PER_SUBMISSION,
|
||||||
@@ -69,6 +72,17 @@
|
|||||||
type WorkerSessionRequestIdentity,
|
type WorkerSessionRequestIdentity,
|
||||||
type WorkerSessionTarget,
|
type WorkerSessionTarget,
|
||||||
} from "$lib/workspace/session-observation";
|
} from "$lib/workspace/session-observation";
|
||||||
|
import {
|
||||||
|
applyConsoleHistoryPage,
|
||||||
|
beginConsoleHistoryRequest,
|
||||||
|
emptyConsoleHistoryState,
|
||||||
|
failConsoleHistoryRequest,
|
||||||
|
historyEntries,
|
||||||
|
setConsoleHistoryTopEdge,
|
||||||
|
shouldLoadHistoryAtTop,
|
||||||
|
type ConsoleHistoryState,
|
||||||
|
} from "$lib/workspace/console/history";
|
||||||
|
import { buildTurnNavigationItems, type TurnNavigationItem } from "$lib/workspace/console/turn-navigation";
|
||||||
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
|
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -200,6 +214,7 @@
|
|||||||
let timelineOpen = $state(false);
|
let timelineOpen = $state(false);
|
||||||
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
||||||
let consoleBodyElement: HTMLElement | null = null;
|
let consoleBodyElement: HTMLElement | null = null;
|
||||||
|
let turnNavigationElement = $state<HTMLElement | null>(null);
|
||||||
let composerInputElement = $state<
|
let composerInputElement = $state<
|
||||||
(SvelteComponent & ComposerInputHandle) | null
|
(SvelteComponent & ComposerInputHandle) | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -222,6 +237,8 @@
|
|||||||
let consoleProjection = $state.raw<ConsoleProjection>(
|
let consoleProjection = $state.raw<ConsoleProjection>(
|
||||||
consoleProjector.snapshot(),
|
consoleProjector.snapshot(),
|
||||||
);
|
);
|
||||||
|
let historyByView = $state.raw<Record<string, ConsoleHistoryState>>({});
|
||||||
|
const pendingHistoryRefreshes = new Set<string>();
|
||||||
let pendingObservationEvents: ConsoleEventInput[] = [];
|
let pendingObservationEvents: ConsoleEventInput[] = [];
|
||||||
let pendingInitialSnapshotApplication: {
|
let pendingInitialSnapshotApplication: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@@ -251,9 +268,30 @@
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const selectedConsoleProjection = $derived(selectedWorkerView.console);
|
const selectedConsoleProjection = $derived(selectedWorkerView.console);
|
||||||
const lines = $derived(
|
const selectedHistoryKey = $derived(
|
||||||
|
`${workspaceId}:${runtimeId ?? ""}:${workerId ?? ""}:${selectedWorkerViewSessionId ?? "main"}`,
|
||||||
|
);
|
||||||
|
const selectedHistory = $derived(
|
||||||
|
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
||||||
|
);
|
||||||
|
const currentLines = $derived(
|
||||||
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
|
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
|
||||||
);
|
);
|
||||||
|
const committedHistoryLines = $derived(
|
||||||
|
projectConsoleLines(
|
||||||
|
projectSessionHistoryEntries(
|
||||||
|
historyEntries(selectedHistory),
|
||||||
|
selectedConsoleProjection.cwd,
|
||||||
|
),
|
||||||
|
consoleViewMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const lines = $derived(
|
||||||
|
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
|
||||||
|
);
|
||||||
|
const turnNavigationItems = $derived(
|
||||||
|
buildTurnNavigationItems(selectedHistory.turns),
|
||||||
|
);
|
||||||
const tasks = $derived(selectedConsoleProjection.tasks);
|
const tasks = $derived(selectedConsoleProjection.tasks);
|
||||||
const timelineLayout = $derived(
|
const timelineLayout = $derived(
|
||||||
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
|
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
|
||||||
@@ -309,6 +347,203 @@
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkerSessionHistoryResponse =
|
||||||
|
| { availability: "page"; page: SessionHistoryPage }
|
||||||
|
| {
|
||||||
|
availability: "unavailable";
|
||||||
|
reason: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mainHistoryKey(target: ConsoleTarget): string {
|
||||||
|
return `${target.workspaceId}:${target.runtimeId}:${target.workerId}:main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyStateFor(key: string): ConsoleHistoryState {
|
||||||
|
return historyByView[key] ?? emptyConsoleHistoryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHistoryState(key: string, state: ConsoleHistoryState) {
|
||||||
|
historyByView = { ...historyByView, [key]: state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestHistoryPage(
|
||||||
|
target: ConsoleTarget,
|
||||||
|
key: string,
|
||||||
|
cursor: string | null,
|
||||||
|
token: number,
|
||||||
|
) {
|
||||||
|
const started = beginConsoleHistoryRequest(historyStateFor(key), cursor);
|
||||||
|
if (!started) return;
|
||||||
|
setHistoryState(key, started);
|
||||||
|
|
||||||
|
const anchors = cursor === null ? null : captureHistoryAnchors();
|
||||||
|
const query = new URLSearchParams({ limit: "5" });
|
||||||
|
if (cursor) query.set("cursor", cursor);
|
||||||
|
const path = workerApiPath(
|
||||||
|
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session/history?${query}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (response.status === 404 || response.status === 405) {
|
||||||
|
throw new Error("Earlier conversation is not supported by this Runtime version.");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`History request failed (${response.status}).`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as WorkerSessionHistoryResponse;
|
||||||
|
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
|
||||||
|
if (payload.availability === "unavailable") {
|
||||||
|
setHistoryState(
|
||||||
|
key,
|
||||||
|
failConsoleHistoryRequest(historyStateFor(key), payload.message),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHistoryState(
|
||||||
|
key,
|
||||||
|
applyConsoleHistoryPage(historyStateFor(key), payload.page, cursor),
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
restoreHistoryAnchors(anchors);
|
||||||
|
} catch (error) {
|
||||||
|
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
|
||||||
|
setHistoryState(
|
||||||
|
key,
|
||||||
|
failConsoleHistoryRequest(
|
||||||
|
historyStateFor(key),
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
const refreshAgain = pendingHistoryRefreshes.delete(key);
|
||||||
|
if (
|
||||||
|
refreshAgain &&
|
||||||
|
token === reloadToken &&
|
||||||
|
mainHistoryKey(target) === key
|
||||||
|
) {
|
||||||
|
void requestHistoryPage(target, key, null, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryAnchor = { id: string; top: number };
|
||||||
|
type HistoryAnchors = {
|
||||||
|
transcript: HistoryAnchor | null;
|
||||||
|
navigation: HistoryAnchor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function captureElementAnchor(
|
||||||
|
container: HTMLElement | null,
|
||||||
|
selector: string,
|
||||||
|
dataKey: "consoleLineId" | "turnId",
|
||||||
|
): HistoryAnchor | null {
|
||||||
|
const first = container?.querySelector<HTMLElement>(selector);
|
||||||
|
const id = first?.dataset[dataKey] ?? "";
|
||||||
|
return first && id ? { id, top: first.getBoundingClientRect().top } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureHistoryAnchors(): HistoryAnchors {
|
||||||
|
return {
|
||||||
|
transcript: captureElementAnchor(
|
||||||
|
consoleBodyElement,
|
||||||
|
"[data-console-line-id]",
|
||||||
|
"consoleLineId",
|
||||||
|
),
|
||||||
|
navigation: captureElementAnchor(
|
||||||
|
turnNavigationElement,
|
||||||
|
"[data-turn-id]",
|
||||||
|
"turnId",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreElementAnchor(
|
||||||
|
container: HTMLElement | null,
|
||||||
|
selector: string,
|
||||||
|
anchor: HistoryAnchor | null,
|
||||||
|
) {
|
||||||
|
if (!anchor || !container) return;
|
||||||
|
const target = container.querySelector<HTMLElement>(selector);
|
||||||
|
if (!target) return;
|
||||||
|
container.scrollTop += target.getBoundingClientRect().top - anchor.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreHistoryAnchors(anchors: HistoryAnchors | null) {
|
||||||
|
if (!anchors) return;
|
||||||
|
restoreElementAnchor(
|
||||||
|
consoleBodyElement,
|
||||||
|
`[data-console-line-id="${cssEscape(anchors.transcript?.id ?? "")}"]`,
|
||||||
|
anchors.transcript,
|
||||||
|
);
|
||||||
|
restoreElementAnchor(
|
||||||
|
turnNavigationElement,
|
||||||
|
`[data-turn-id="${cssEscape(anchors.navigation?.id ?? "")}"]`,
|
||||||
|
anchors.navigation,
|
||||||
|
);
|
||||||
|
updateConsoleScrollMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEarlierHistory() {
|
||||||
|
const target = consoleTarget;
|
||||||
|
if (!target || selectedWorkerViewSessionId !== null) return;
|
||||||
|
const key = mainHistoryKey(target);
|
||||||
|
const state = historyStateFor(key);
|
||||||
|
if (!state.hasMore || !state.cursor) return;
|
||||||
|
void requestHistoryPage(target, key, state.cursor, reloadToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryHistoryLoad() {
|
||||||
|
const target = consoleTarget;
|
||||||
|
if (!target || selectedWorkerViewSessionId !== null) return;
|
||||||
|
const key = mainHistoryKey(target);
|
||||||
|
const state = historyStateFor(key);
|
||||||
|
void requestHistoryPage(
|
||||||
|
target,
|
||||||
|
key,
|
||||||
|
state.hasMore ? state.cursor : null,
|
||||||
|
reloadToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCurrentHistory() {
|
||||||
|
const target = consoleTarget;
|
||||||
|
if (!target) return;
|
||||||
|
const key = mainHistoryKey(target);
|
||||||
|
if (historyStateFor(key).status === "loading") {
|
||||||
|
pendingHistoryRefreshes.add(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void requestHistoryPage(target, key, null, reloadToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistoryTopEdge(atTop: boolean) {
|
||||||
|
if (selectedWorkerViewSessionId !== null) return;
|
||||||
|
const key = selectedHistoryKey;
|
||||||
|
const state = historyStateFor(key);
|
||||||
|
if (shouldLoadHistoryAtTop(state, atTop)) {
|
||||||
|
loadEarlierHistory();
|
||||||
|
} else if (!atTop) {
|
||||||
|
const rearmed = setConsoleHistoryTopEdge(state, false);
|
||||||
|
if (rearmed !== state) setHistoryState(key, rearmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToConversationTurn(item: TurnNavigationItem) {
|
||||||
|
const line = lines.find((candidate) => candidate.entryId === item.lineId);
|
||||||
|
const target = line
|
||||||
|
? consoleBodyElement?.querySelector(
|
||||||
|
`[data-console-line-id="${cssEscape(line.id)}"]`,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWorker(target: ConsoleTarget, token: number) {
|
async function loadWorker(target: ConsoleTarget, token: number) {
|
||||||
workerError = null;
|
workerError = null;
|
||||||
try {
|
try {
|
||||||
@@ -489,6 +724,13 @@
|
|||||||
} else if (payload.event === "pending_submissions_changed") {
|
} else if (payload.event === "pending_submissions_changed") {
|
||||||
pendingSubmissions = payload.data.pending;
|
pendingSubmissions = payload.data.pending;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
payload.event === "segment_rotated" ||
|
||||||
|
payload.event === "user_message" ||
|
||||||
|
payload.event === "session_entry_committed"
|
||||||
|
) {
|
||||||
|
refreshCurrentHistory();
|
||||||
|
}
|
||||||
if (payload.event === "error") {
|
if (payload.event === "error") {
|
||||||
queueObservationDiagnostic({
|
queueObservationDiagnostic({
|
||||||
code: payload.data.code,
|
code: payload.data.code,
|
||||||
@@ -1093,6 +1335,9 @@
|
|||||||
];
|
];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
untrack(() => {
|
||||||
|
void requestHistoryPage(target, mainHistoryKey(target), null, token);
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
action.kind === "subscribe_live" &&
|
action.kind === "subscribe_live" &&
|
||||||
targetWorker &&
|
targetWorker &&
|
||||||
@@ -1769,6 +2014,7 @@
|
|||||||
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
||||||
updateConsoleScrollMetrics();
|
updateConsoleScrollMetrics();
|
||||||
rememberConsoleWorkerViewScroll();
|
rememberConsoleWorkerViewScroll();
|
||||||
|
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollConsoleToBottom() {
|
async function scrollConsoleToBottom() {
|
||||||
@@ -1988,19 +2234,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class:with-task-pane={taskPaneOpen} class="console-history">
|
<div class:with-task-pane={taskPaneOpen} class="console-history">
|
||||||
<section class:timeline-open={timelineOpen} class="console-body">
|
<section class="console-body">
|
||||||
<div class="console-timeline-spacer" aria-hidden="true"></div>
|
|
||||||
<div class="timeline-fold-cell">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="timeline-fold"
|
|
||||||
aria-expanded={timelineOpen}
|
|
||||||
aria-label={timelineOpen ? "Hide timeline" : "Show timeline"}
|
|
||||||
onclick={() => (timelineOpen = !timelineOpen)}
|
|
||||||
>
|
|
||||||
{timelineOpen ? "Timeline ◂" : "Timeline ▸"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="console-scroll"
|
class="console-scroll"
|
||||||
bind:this={consoleBodyElement}
|
bind:this={consoleBodyElement}
|
||||||
@@ -2014,6 +2248,24 @@
|
|||||||
<p class="error">{workerError}</p>
|
<p class="error">{workerError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")}
|
||||||
|
<div class="conversation-history-boundary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selectedHistory.status === "loading"}
|
||||||
|
onclick={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
|
||||||
|
>
|
||||||
|
{selectedHistory.status === "loading"
|
||||||
|
? "Loading earlier conversation…"
|
||||||
|
: selectedHistory.error
|
||||||
|
? "Earlier conversation unavailable · retry"
|
||||||
|
: "Earlier conversation available · load 5 turns"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if selectedWorkerViewSessionId === null && selectedHistory.status === "ready"}
|
||||||
|
<div class="conversation-history-boundary complete">Start of conversation</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ConsoleDisplayStateView
|
<ConsoleDisplayStateView
|
||||||
state={consoleDisplayState}
|
state={consoleDisplayState}
|
||||||
hasContent={lines.length > 0}
|
hasContent={lines.length > 0}
|
||||||
@@ -2031,13 +2283,18 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConsoleTimeline
|
<ConsoleTurnNavigation
|
||||||
marks={timelineMarks}
|
items={turnNavigationItems}
|
||||||
thumbStyle={timelineThumb}
|
hasMore={selectedWorkerViewSessionId === null && selectedHistory.hasMore}
|
||||||
axisStyle={timelineAxisStyle}
|
loading={selectedHistory.status === "loading"}
|
||||||
expanded={timelineOpen}
|
error={selectedWorkerViewSessionId === null
|
||||||
onRailPointerDown={handleTimelineRailPointerDown}
|
? selectedHistory.error
|
||||||
onMarkClick={jumpToTimelineMark}
|
: "Earlier history is unavailable for direct SubWorker views."}
|
||||||
|
onLoadMore={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
|
||||||
|
onTopEdgeChange={handleHistoryTopEdge}
|
||||||
|
onTurnClick={jumpToConversationTurn}
|
||||||
|
historyAvailable={selectedWorkerViewSessionId === null}
|
||||||
|
bind:element={turnNavigationElement}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2446,57 +2703,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.console-body {
|
.console-body {
|
||||||
--console-timeline-width: 12rem;
|
|
||||||
--console-timeline-fold-width: 2.25rem;
|
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) var(--console-timeline-fold-width);
|
grid-template-columns: minmax(0, 1fr) minmax(10rem, 12rem);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
gap: 0 var(--space-3);
|
gap: 0 var(--space-3);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: grid-template-columns 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-body.timeline-open {
|
|
||||||
grid-template-columns: minmax(0, 1fr) var(--console-timeline-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-timeline-spacer {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-fold-cell {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-fold {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-size-compact);
|
|
||||||
font-weight: 800;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-fold:focus-visible {
|
|
||||||
border-color: var(--tui-cyan);
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-scroll {
|
.console-scroll {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -2510,6 +2725,36 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-history-boundary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-history-boundary button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tui-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-history-boundary button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-history-boundary.complete::before,
|
||||||
|
.conversation-history-boundary.complete::after {
|
||||||
|
content: "";
|
||||||
|
align-self: center;
|
||||||
|
width: 2rem;
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
.pending-submissions {
|
.pending-submissions {
|
||||||
margin: 0 var(--space-3);
|
margin: 0 var(--space-3);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
+245
-5
@@ -106,6 +106,52 @@ function sessionWithUserMessage(content: string): SessionSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyHistoryPage() {
|
||||||
|
return {
|
||||||
|
availability: "page",
|
||||||
|
page: {
|
||||||
|
session_id: "session-a",
|
||||||
|
lineage_id: "lineage-a",
|
||||||
|
turns: [],
|
||||||
|
next_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyPage(indices: number[], cursor: string | null) {
|
||||||
|
return {
|
||||||
|
availability: "page",
|
||||||
|
page: {
|
||||||
|
session_id: "session-a",
|
||||||
|
lineage_id: "lineage-a",
|
||||||
|
turns: indices.map((index) => ({
|
||||||
|
turn_id: `user-${index}`,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
entry_id: `user-${index}`,
|
||||||
|
timestamp: index,
|
||||||
|
provenance: "human_input",
|
||||||
|
kind: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ kind: "text", text: `question ${index}` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: `assistant-${index}`,
|
||||||
|
timestamp: index,
|
||||||
|
provenance: "model_output",
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: `done ${index}` }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
next_cursor: cursor,
|
||||||
|
has_more: cursor !== null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotEvent(session = emptySession()): ProtocolEvent {
|
function snapshotEvent(session = emptySession()): ProtocolEvent {
|
||||||
return {
|
return {
|
||||||
event: "snapshot",
|
event: "snapshot",
|
||||||
@@ -256,12 +302,21 @@ test("applies retained empty and nonempty snapshots before showing their read-on
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("shows bounded initial failures and retries explicitly", async () => {
|
test("shows bounded initial failures and retries explicitly", async () => {
|
||||||
const fetchMock = vi.fn()
|
let sessionRequests = 0;
|
||||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
.mockResolvedValueOnce(Response.json({
|
if (String(input).includes("/session/history")) {
|
||||||
|
return Promise.resolve(Response.json(emptyHistoryPage()));
|
||||||
|
}
|
||||||
|
sessionRequests += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
sessionRequests === 1
|
||||||
|
? new Response(null, { status: 503 })
|
||||||
|
: Response.json({
|
||||||
availability: "retained_snapshot",
|
availability: "retained_snapshot",
|
||||||
snapshot: emptySession(),
|
snapshot: emptySession(),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
render(ConsolePage, { data: pageData() });
|
render(ConsolePage, { data: pageData() });
|
||||||
|
|
||||||
@@ -271,7 +326,7 @@ test("shows bounded initial failures and retries explicitly", async () => {
|
|||||||
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
|
||||||
expect(await screen.findByText("No conversation to display")).not.toBeNull();
|
expect(await screen.findByText("No conversation to display")).not.toBeNull();
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(sessionRequests).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("distinguishes typed unavailability and an initial live connection closure", async () => {
|
test("distinguishes typed unavailability and an initial live connection closure", async () => {
|
||||||
@@ -327,6 +382,9 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
|||||||
const current = deferred<Response>();
|
const current = deferred<Response>();
|
||||||
let firstWorkerLoads = 0;
|
let firstWorkerLoads = 0;
|
||||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("/session/history")) {
|
||||||
|
return Promise.resolve(Response.json(emptyHistoryPage()));
|
||||||
|
}
|
||||||
if (String(input).includes("worker-b")) {
|
if (String(input).includes("worker-b")) {
|
||||||
return current.promise.then((value) => value.clone());
|
return current.promise.then((value) => value.clone());
|
||||||
}
|
}
|
||||||
@@ -370,3 +428,185 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
|||||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||||
expect(screen.getByText("current worker content")).not.toBeNull();
|
expect(screen.getByText("current worker content")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loads retained conversation turns five at a time into the log and turn bar", async () => {
|
||||||
|
const historyPages = [
|
||||||
|
{
|
||||||
|
availability: "page",
|
||||||
|
page: {
|
||||||
|
session_id: "session-a",
|
||||||
|
lineage_id: "lineage-a",
|
||||||
|
turns: [8, 9, 10, 11, 12].map((index) => ({
|
||||||
|
turn_id: `user-${index}`,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
entry_id: `user-${index}`,
|
||||||
|
timestamp: index,
|
||||||
|
provenance: "human_input",
|
||||||
|
kind: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ kind: "text", text: `question ${index}` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: `assistant-${index}`,
|
||||||
|
timestamp: index,
|
||||||
|
provenance: "model_output",
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: `done ${index}` }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
next_cursor: "older-8",
|
||||||
|
has_more: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
availability: "page",
|
||||||
|
page: {
|
||||||
|
session_id: "session-a",
|
||||||
|
lineage_id: "lineage-a",
|
||||||
|
turns: [3, 4, 5, 6, 7].map((index) => ({
|
||||||
|
turn_id: `user-${index}`,
|
||||||
|
entries: [{
|
||||||
|
entry_id: `user-${index}`,
|
||||||
|
timestamp: index,
|
||||||
|
provenance: "human_input",
|
||||||
|
kind: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ kind: "text", text: `question ${index}` }],
|
||||||
|
}],
|
||||||
|
})),
|
||||||
|
next_cursor: "older-3",
|
||||||
|
has_more: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
historyPage([1, 2], null),
|
||||||
|
];
|
||||||
|
let historyRequests = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("/session/history")) {
|
||||||
|
return Promise.resolve(Response.json(historyPages[historyRequests++]));
|
||||||
|
}
|
||||||
|
return Promise.resolve(Response.json({ availability: "live_protocol" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(ConsolePage, { data: pageData() });
|
||||||
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||||
|
latestListener().onFrame(subscribedFrame());
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText("question 8")).toHaveLength(2)
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("done 12")).toHaveLength(2);
|
||||||
|
const transcriptScroller = screen.getByLabelText("main transcript")
|
||||||
|
.parentElement as HTMLElement;
|
||||||
|
transcriptScroller.scrollTop = 0;
|
||||||
|
await fireEvent.scroll(transcriptScroller);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText("question 3")).toHaveLength(2)
|
||||||
|
);
|
||||||
|
expect(historyRequests).toBe(2);
|
||||||
|
|
||||||
|
const navigation = screen.getByLabelText("Conversation turns");
|
||||||
|
const navigationList = navigation.firstElementChild as HTMLElement;
|
||||||
|
navigationList.scrollTop = 0;
|
||||||
|
await fireEvent.scroll(navigationList);
|
||||||
|
await fireEvent.scroll(navigationList);
|
||||||
|
transcriptScroller.scrollTop = 0;
|
||||||
|
await fireEvent.scroll(transcriptScroller);
|
||||||
|
await settleMicrotasks();
|
||||||
|
expect(historyRequests).toBe(2);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Earlier conversation available · load 5 turns",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText("question 1")).toHaveLength(2)
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Start of conversation")).toHaveLength(2);
|
||||||
|
expect(historyRequests).toBe(3);
|
||||||
|
|
||||||
|
const scrollIntoView = vi.fn();
|
||||||
|
vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(
|
||||||
|
scrollIntoView,
|
||||||
|
);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Jump to conversation: question 8",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps live content when history fails and retries the bounded page explicitly", async () => {
|
||||||
|
let historyRequests = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("/session/history")) {
|
||||||
|
historyRequests += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
historyRequests === 1
|
||||||
|
? new Response(null, { status: 503 })
|
||||||
|
: Response.json(historyPage([1, 2, 3, 4], null)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(Response.json({ availability: "live_protocol" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(ConsolePage, { data: pageData() });
|
||||||
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||||
|
latestListener().onFrame(
|
||||||
|
subscribedFrame(sessionWithUserMessage("current response remains")),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("current response remains")).not.toBeNull();
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: "Earlier conversation unavailable · retry",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText("question 1")).toHaveLength(2)
|
||||||
|
);
|
||||||
|
expect(historyRequests).toBe(2);
|
||||||
|
expect(screen.getByText("current response remains")).not.toBeNull();
|
||||||
|
expect(screen.getAllByText("Start of conversation")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fences delayed history pages when the selected Worker route changes", async () => {
|
||||||
|
const stale = deferred<Response>();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes("/session/history")) {
|
||||||
|
return path.includes("worker-b")
|
||||||
|
? Promise.resolve(Response.json(historyPage([21], null)))
|
||||||
|
: stale.promise;
|
||||||
|
}
|
||||||
|
return Promise.resolve(Response.json({
|
||||||
|
availability: "retained_snapshot",
|
||||||
|
snapshot: emptySession(),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = render(ConsolePage, { data: pageData() });
|
||||||
|
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText("question 21")).toHaveLength(2)
|
||||||
|
);
|
||||||
|
|
||||||
|
stale.resolve(Response.json(historyPage([99], null)));
|
||||||
|
await settleMicrotasks();
|
||||||
|
expect(screen.queryByText("question 99")).toBeNull();
|
||||||
|
expect(screen.getAllByText("question 21")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user