fix: restore console input and attachment workflows

Restore durable busy Submit queues and TUI Enter delivery, expose Web Queue beside Notify, and separate the Composer toolbar from its editor without persistent success notices.

Support pasted image attachments and restore canonical Worker Session uploaded-file operations, including retention, binding, cleanup and authenticated upload context.

Validated Worker, Protocol and Session Store suites, 278 TUI tests, 59 Web tests and Svelte check. Runtime suite could not complete due to external Workdir transport timeouts.
This commit is contained in:
2026-09-30 05:16:57 +09:00
parent 677c7cfed9
commit 2006b390b4
20 changed files with 1478 additions and 339 deletions
+12 -30
View File
@@ -759,12 +759,6 @@ impl App {
}
return None;
}
if self.worker_status != WorkerStatus::Idle {
self.push_error(
"Submit requires an idle Worker; use Alt-n Notify for work already in progress.",
);
return None;
}
self.record_input_history(segments.clone());
self.input.clear();
Some(self.method_for_run(segments))
@@ -779,9 +773,7 @@ impl App {
.iter()
.any(|segment| matches!(segment, Segment::UploadedFile { .. }))
{
self.push_error(
"Notify accepts text only; remove attachments or wait until the Worker is idle to Submit.",
);
self.push_error("Notify accepts text only; remove attachments or queue a Submit.");
return None;
}
let message = Segment::flatten_to_text(&segments);
@@ -3522,22 +3514,16 @@ mod completion_flow_tests {
}
#[test]
fn running_submit_is_rejected_locally_and_preserves_the_draft() {
fn running_submit_is_sent_to_the_worker_and_not_queued_locally() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "advisory context");
insert_text(&mut app, "queued turn");
let method = app.submit_input();
assert!(method.is_none());
assert!(matches!(method, Some(Method::Submit { .. })));
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "advisory context");
assert!(app.blocks.iter().any(|block| matches!(
block,
Block::Alert { message, .. }
if message.contains("requires an idle Worker")
&& message.contains("Alt-n Notify")
)));
assert_eq!(input_text(&app), "");
}
#[test]
@@ -3569,14 +3555,15 @@ mod completion_flow_tests {
}
#[test]
fn paused_non_empty_submit_is_rejected_locally_and_preserves_the_draft() {
fn paused_non_empty_submit_is_sent_without_resuming() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Paused);
insert_text(&mut app, "different request");
insert_text(&mut app, "next turn");
assert!(app.submit_input().is_none());
assert_eq!(input_text(&app), "different request");
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert_eq!(app.worker_status, WorkerStatus::Paused);
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "");
}
#[test]
@@ -4634,18 +4621,13 @@ mod completion_flow_tests {
}
#[test]
fn input_history_records_only_an_idle_submit_after_running_rejection() {
fn input_history_records_running_submits_and_suppresses_consecutive_duplicates() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
app.running = true;
for c in "repeat".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert_eq!(app.input_history_len(), 0);
assert_eq!(input_text(&app), "repeat");
app.set_worker_status(WorkerStatus::Idle);
assert!(app.submit_input().is_some());
assert_eq!(app.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 0);
+16 -10
View File
@@ -1694,8 +1694,8 @@ mod tests {
assert!(connection.awaiting_attachment_acceptance.is_empty());
}
#[test]
fn running_attachment_submit_is_rejected_without_consuming_the_draft() {
#[tokio::test]
async fn running_attachment_submit_failure_restores_draft_without_exiting_console() {
let file = UploadedFileRef {
artifact_id: "artifact-queued".into(),
file_name: "queued.txt".into(),
@@ -1706,7 +1706,7 @@ mod tests {
sha256: "a".repeat(64),
source_entry_id: None,
};
let connection = ConsoleConnection {
let mut connection = ConsoleConnection {
client: Client::new(FailOnceSocket {
fail_next_send: true,
}),
@@ -1721,10 +1721,13 @@ mod tests {
let mut app = App::new("worker".into());
app.set_worker_status(WorkerStatus::Running);
app.input.insert_str("queued inspect");
assert!(
app.submit_input().is_none(),
"running Submit must be rejected before transport"
);
let method = app
.submit_input()
.expect("running Submit is sent immediately");
send_console_method(&mut app, &mut connection, &method)
.await
.unwrap();
assert_eq!(app.input.plain_text(), "queued inspect");
assert_eq!(connection.pending_attachments, vec![file]);
@@ -2044,7 +2047,7 @@ mod tests {
}
#[test]
fn running_enter_rejects_submit_and_preserves_the_draft() {
fn running_enter_sends_submit_to_worker() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() {
@@ -2057,10 +2060,13 @@ mod tests {
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(matches!(
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(Method::Submit { .. })
));
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "queued");
assert_eq!(input_text(&app), "");
}
#[test]
+1 -1
View File
@@ -1868,7 +1868,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
" — Enter to resume, Ctrl-X to cancel, Alt-n to notify",
" — Enter on empty input to resume, type + Enter to queue, Ctrl-X to cancel",
Style::default().fg(Color::DarkGray),
));
} else if !app.running {