feat(backend,frontend): lot utilisateur #310 #311 #312 #313 — horodatage conversation, stop agent robuste, préférence terminal par défaut, paramètres projet (QA verte)
- #310: horodatage de tous les messages et événements de conversation visibles — composant partagé EntryTimestamp, timestamps propagés dans le transcript chat, les annonces inter-agent et le viewer de conversation, normalisation côté domain/adapters (conversation_log, paginate). - #311: stop d'agent robuste pour PTY non monté, sessions headless et CLI custom — lifecycle/orchestrator durcis, ponts chat app-tauri/web-server alignés, arrêt fiable sans session orpheline. - #312: préférence globale d'interface terminal par défaut — nouveau store settings (domain/application/infrastructure + tests), DTO et commandes backend, adapter frontend userSettings, panneau TerminalSettings dans les paramètres globaux. - #313: Paramètres du projet — contexte de projet éditable et couleur de thème par projet (ProjectSettingsPanel + projectTheme), teinte des onglets projet, persistance via DTO projet. QA verte (GO QA sur feature/batch-310-311-312-313-chat-stop-prefs-theme) — vérifications Git: git diff --cached --check OK; staging borné à crates/ et frontend/ (74 fichiers, +2105/-531), commit unique de lot conforme à la convention des lots (précédents batch-151/158/159, batch-210/211/212) car les fichiers partagés (dto.rs, commands.rs, CustomAgentChatView) entrelacent les 4 tickets. fichiers .ideai/* (agents/test.md, chat-drafts.json, system-permissions.json, tickets/counter.json, tickets/index.json, tickets/308-313/), attachments/ et scripts/ hors périmètre et préservés en worktree ; aucun push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -93,7 +93,7 @@ use backend::dto::{
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
||||
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
|
||||
RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
@ -116,7 +116,8 @@ use backend::dto::{
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
|
||||
UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto,
|
||||
};
|
||||
use backend::events::DomainEventDto;
|
||||
type PtyChunk = Vec<u8>;
|
||||
@ -1647,6 +1648,10 @@ async fn execute_launch_agent_for_ws(
|
||||
mcp_runtime,
|
||||
allow_structured_alongside_pty: false,
|
||||
require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat),
|
||||
terminal_interface: request.cell_kind.map(|kind| match kind {
|
||||
backend::dto::CellKind::Pty => domain::DefaultAgentTerminalInterface::NativeTui,
|
||||
backend::dto::CellKind::Chat => domain::DefaultAgentTerminalInterface::IdeaCli,
|
||||
}),
|
||||
fresh_start: request.fresh_start,
|
||||
managed_temp_exposure: application::ManagedTempExposureRequest::default(),
|
||||
})
|
||||
@ -1919,6 +1924,7 @@ async fn ws_send_chat(
|
||||
|
||||
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
|
||||
let tap_out = tx.clone();
|
||||
let tap_clock = Arc::clone(&state.app.clock);
|
||||
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
|
||||
let tap_pump = std::thread::spawn(move || {
|
||||
for event in tap_rx {
|
||||
@ -1930,7 +1936,7 @@ async fn ws_send_chat(
|
||||
if matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }) {
|
||||
continue;
|
||||
}
|
||||
let chunk = reply_event_chunk(event);
|
||||
let chunk = reply_event_chunk(event, u64::try_from(tap_clock.now_millis()).ok());
|
||||
if tap_out
|
||||
.blocking_send(ServerFrame::chat_output(None, sid, chunk))
|
||||
.is_err()
|
||||
@ -1978,7 +1984,10 @@ async fn ws_send_chat(
|
||||
break;
|
||||
}
|
||||
backend::StructuredHumanTurnTransition::Failed(message) => {
|
||||
let chunk = reply_event_chunk(ReplyEvent::Error { message });
|
||||
let chunk = reply_event_chunk(
|
||||
ReplyEvent::Error { message },
|
||||
u64::try_from(state.app.clock.now_millis()).ok(),
|
||||
);
|
||||
tx.send(ServerFrame::chat_output(
|
||||
(!sent_ack).then_some(frame.id.as_str()),
|
||||
sid,
|
||||
@ -2002,7 +2011,7 @@ async fn ws_send_chat(
|
||||
guard.mark_alive();
|
||||
}
|
||||
}
|
||||
let chunk = reply_event_chunk(event);
|
||||
let chunk = reply_event_chunk(event, u64::try_from(state.app.clock.now_millis()).ok());
|
||||
if !sent_ack {
|
||||
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk))
|
||||
.await
|
||||
@ -2030,7 +2039,10 @@ async fn ws_send_chat(
|
||||
resume_prompt = Some(prompt);
|
||||
}
|
||||
backend::StructuredHumanTurnTransition::Failed(message) => {
|
||||
let chunk = reply_event_chunk(ReplyEvent::Error { message });
|
||||
let chunk = reply_event_chunk(
|
||||
ReplyEvent::Error { message },
|
||||
u64::try_from(state.app.clock.now_millis()).ok(),
|
||||
);
|
||||
tx.send(ServerFrame::chat_output(
|
||||
(!sent_ack).then_some(frame.id.as_str()),
|
||||
sid,
|
||||
@ -2076,7 +2088,7 @@ async fn ws_send_chat(
|
||||
tx.send(ServerFrame::chat_output(
|
||||
Some(&frame.id),
|
||||
sid,
|
||||
json!({ "kind": "final", "content": "" }),
|
||||
json!({ "kind": "final", "content": "", "atMs": u64::try_from(state.app.clock.now_millis()).ok() }),
|
||||
))
|
||||
.await
|
||||
.map_err(|_| ErrorDto {
|
||||
@ -2112,16 +2124,20 @@ fn ws_detach_chat(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_event_chunk(event: ReplyEvent) -> Value {
|
||||
fn reply_event_chunk(event: ReplyEvent, occurred_at_ms: Option<u64>) -> Value {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => json!({ "kind": "textDelta", "text": text }),
|
||||
ReplyEvent::TextDelta { text } => {
|
||||
json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms })
|
||||
}
|
||||
ReplyEvent::ToolActivity { label } => {
|
||||
json!({ "kind": "toolActivity", "label": label })
|
||||
json!({ "kind": "toolActivity", "label": label, "atMs": occurred_at_ms })
|
||||
}
|
||||
ReplyEvent::Progress { progress } => {
|
||||
json!({ "kind": "progress", "progress": reply_progress_value(progress) })
|
||||
json!({ "kind": "progress", "progress": reply_progress_value(progress, occurred_at_ms) })
|
||||
}
|
||||
ReplyEvent::Announcement { text } => {
|
||||
json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms })
|
||||
}
|
||||
ReplyEvent::Announcement { text } => json!({ "kind": "textDelta", "text": text }),
|
||||
ReplyEvent::Heartbeat => json!({
|
||||
"kind": "progress",
|
||||
"progress": {
|
||||
@ -2129,6 +2145,7 @@ fn reply_event_chunk(event: ReplyEvent) -> Value {
|
||||
"kind": "other",
|
||||
"stage": "info",
|
||||
"label": "heartbeat"
|
||||
,"occurredAtMs": occurred_at_ms
|
||||
}
|
||||
}),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => json!({
|
||||
@ -2138,15 +2155,20 @@ fn reply_event_chunk(event: ReplyEvent) -> Value {
|
||||
"kind": "turn",
|
||||
"stage": "info",
|
||||
"label": "rate_limited",
|
||||
"occurredAtMs": resets_at_ms
|
||||
"occurredAtMs": occurred_at_ms,
|
||||
"resetsAtMs": resets_at_ms
|
||||
}
|
||||
}),
|
||||
ReplyEvent::Error { message } => json!({ "kind": "error", "message": message }),
|
||||
ReplyEvent::Final { content } => json!({ "kind": "final", "content": content }),
|
||||
ReplyEvent::Error { message } => {
|
||||
json!({ "kind": "error", "message": message, "atMs": occurred_at_ms })
|
||||
}
|
||||
ReplyEvent::Final { content } => {
|
||||
json!({ "kind": "final", "content": content, "atMs": occurred_at_ms })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reply_progress_value(progress: ReplyProgress) -> Value {
|
||||
fn reply_progress_value(progress: ReplyProgress, occurred_at_ms: Option<u64>) -> Value {
|
||||
json!({
|
||||
"progressId": progress.progress_id,
|
||||
"source": reply_progress_source(progress.source),
|
||||
@ -2157,6 +2179,7 @@ fn reply_progress_value(progress: ReplyProgress) -> Value {
|
||||
"provider": progress.provider,
|
||||
"nativeEvent": progress.native_event,
|
||||
"toolName": progress.tool_name,
|
||||
"occurredAtMs": occurred_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
@ -3370,6 +3393,12 @@ async fn invoke(
|
||||
"set_active_layout" => invoke_set_active_layout(&request.args, &state.app).await,
|
||||
"read_project_context" => invoke_read_project_context(&request.args, &state.app).await,
|
||||
"update_project_context" => invoke_update_project_context(&request.args, &state.app).await,
|
||||
"read_user_settings" => invoke_read_user_settings(&state.app).await,
|
||||
"update_user_settings" => invoke_update_user_settings(&request.args, &state.app).await,
|
||||
"read_project_settings" => invoke_read_project_settings(&request.args, &state.app).await,
|
||||
"update_project_settings" => {
|
||||
invoke_update_project_settings(&request.args, &state.app).await
|
||||
}
|
||||
"first_run_state" => invoke_first_run_state(&state.app).await,
|
||||
"reference_profiles" => invoke_reference_profiles(&state.app).await,
|
||||
"detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await,
|
||||
@ -3803,6 +3832,66 @@ async fn invoke_update_project_context(
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
async fn invoke_read_user_settings(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output: UserSettingsDto = state
|
||||
.read_user_settings
|
||||
.execute()
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_update_user_settings(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<UserSettingsDto>("update_user_settings", args)?;
|
||||
let output: UserSettingsDto = state
|
||||
.update_user_settings
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_read_project_settings(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let project = resolve_project_readonly(
|
||||
string_arg(args, "projectId", "read_project_settings")?,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let output: ProjectSettingsDto = state
|
||||
.read_project_settings
|
||||
.execute(&project)
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_update_project_settings(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request =
|
||||
required_request::<UpdateProjectSettingsRequestDto>("update_project_settings", args)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||
let theme_color = request
|
||||
.theme_color
|
||||
.map(domain::ThemeColor::new)
|
||||
.transpose()
|
||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||
let output: ProjectSettingsDto = state
|
||||
.update_project_settings
|
||||
.execute(&project, domain::ProjectSettings { theme_color })
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_first_run_state(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output = state
|
||||
.first_run_state
|
||||
@ -3991,6 +4080,7 @@ async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result<Val
|
||||
.execute(StopLiveAgentInput {
|
||||
project,
|
||||
agent_id: parse_agent_id(&request.agent_id)?,
|
||||
reason: request.reason,
|
||||
})
|
||||
.await
|
||||
.map(StopLiveAgentResponseDto::from)
|
||||
@ -8021,22 +8111,19 @@ mod tests {
|
||||
assert_eq!(ack.kind, "chat.output");
|
||||
assert_eq!(ack.reply_to.as_deref(), Some("chat-send"));
|
||||
assert_eq!(ack.payload["sessionId"], session_id.to_string());
|
||||
assert_eq!(
|
||||
ack.payload["chunk"],
|
||||
json!({ "kind": "textDelta", "text": "Hello " })
|
||||
);
|
||||
assert_eq!(ack.payload["chunk"]["kind"], "textDelta");
|
||||
assert_eq!(ack.payload["chunk"]["text"], "Hello ");
|
||||
assert!(ack.payload["chunk"]["atMs"].is_number());
|
||||
assert_eq!(tool.kind, "chat.output");
|
||||
assert!(tool.reply_to.is_none());
|
||||
assert_eq!(
|
||||
tool.payload["chunk"],
|
||||
json!({ "kind": "toolActivity", "label": "read_file" })
|
||||
);
|
||||
assert_eq!(tool.payload["chunk"]["kind"], "toolActivity");
|
||||
assert_eq!(tool.payload["chunk"]["label"], "read_file");
|
||||
assert!(tool.payload["chunk"]["atMs"].is_number());
|
||||
assert_eq!(final_chunk.kind, "chat.output");
|
||||
assert!(final_chunk.reply_to.is_none());
|
||||
assert_eq!(
|
||||
final_chunk.payload["chunk"],
|
||||
json!({ "kind": "final", "content": "Hello back" })
|
||||
);
|
||||
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
|
||||
assert_eq!(final_chunk.payload["chunk"]["content"], "Hello back");
|
||||
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
|
||||
assert_eq!(
|
||||
session.prompts.lock().unwrap().as_slice(),
|
||||
&["Salut".to_owned()]
|
||||
@ -8087,10 +8174,9 @@ mod tests {
|
||||
|
||||
assert_eq!(final_chunk.kind, "chat.output");
|
||||
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-busy"));
|
||||
assert_eq!(
|
||||
final_chunk.payload["chunk"],
|
||||
json!({ "kind": "final", "content": "released" })
|
||||
);
|
||||
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
|
||||
assert_eq!(final_chunk.payload["chunk"]["content"], "released");
|
||||
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
|
||||
assert!(
|
||||
!state.app.turn_watch_input.busy_state(runtime_key).is_busy(),
|
||||
"direct structured human turn should be idle after final"
|
||||
@ -8161,10 +8247,9 @@ mod tests {
|
||||
"idea_ask_agent"
|
||||
);
|
||||
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-progress"));
|
||||
assert_eq!(
|
||||
final_chunk.payload["chunk"],
|
||||
json!({ "kind": "final", "content": "done" })
|
||||
);
|
||||
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
|
||||
assert_eq!(final_chunk.payload["chunk"]["content"], "done");
|
||||
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -10262,6 +10347,49 @@ mod tests {
|
||||
assert_eq!(tasks[0]["state"], "queued");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
|
||||
let state = state();
|
||||
let project_id = create_project_for_test(&state, "Settings Round Trip").await;
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
|
||||
let (status, initial_user) =
|
||||
invoke_request_for_test(Arc::clone(&state), &cookie, "read_user_settings", json!({}))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(initial_user["defaultAgentTerminalInterface"], Value::Null);
|
||||
|
||||
let (status, saved_user) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"update_user_settings",
|
||||
json!({ "request": { "defaultAgentTerminalInterface": "ideaCli" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved_user["defaultAgentTerminalInterface"], "ideaCli");
|
||||
|
||||
let (status, initial_project) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"read_project_settings",
|
||||
json!({ "projectId": project_id }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(initial_project["themeColor"], Value::Null);
|
||||
|
||||
let (status, saved_project) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"update_project_settings",
|
||||
json!({ "request": { "projectId": project_id, "themeColor": "#AA3366" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved_project["themeColor"], "#AA3366");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_and_sprint_commands_require_authenticated_invoke() {
|
||||
let state = state();
|
||||
|
||||
Reference in New Issue
Block a user