feat: run turns in several flow chat conversations at once (#11202)

* feat: run turns in several flow chat conversations at once

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep finished turns finished and cached chats current in the flow chat pool

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: attribute a turn's rows by job id as well as sequence

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: count only real stream updates and retry the job-id read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a chat that holds an unsent draft, and take one back when its first turn is withdrawn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ignore a stale running-turn snapshot, keep a withdrawn chat's draft, poll after clean stream ends

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow the turn running now when the listing named one already over

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep replacement turns and SSE fallback moving

* fix: keep replacement turn handoffs active

* fix: preserve unread badge line height

* fix: settle local fallback handoffs

* fix: settle refused turn handoffs

* fix: scope turn handoffs to conversation

* fix: drop stale turn handoffs

* refactor: move the queued message and 409 handling into per-conversation turns

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address cubic's review of the parallel flow chat turns

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clear a stale failure on refresh, and tighten the docs and test waits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: recover running rows past the first page, and drop the failure a re-read disproves

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: check the running-turn query at compile time, and narrow what a refresh clears

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle a failed turn only from an answer that turn wrote

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle a failed turn from its own answer, and only while it is still the failure shown

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop a failure whose answer arrived even when a newer turn owns the error

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: free an answered failure whatever the turn that started meanwhile is doing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop a rows read that a turn outran, rather than merging it under newer messages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop a rows read whose conversation was left and opened again

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hand over a file still being read when its composer goes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: count a drop's routing as work in flight, so its file is handed over too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hold the send until every file a conversation is owed has landed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: keep a panel mounted per conversation instead of handing its draft over

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the withdrawn chat whose composer was written in, not the empty one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the chat in front of the reader when both withdrawn composers were written in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a retry's own run arguments when a turn elsewhere refuses it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: name panels apart across pools, and read a flow's inputs when its chat is built

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-29 11:36:30 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent e68ff0d969
commit f367eaf6d0
34 changed files with 3638 additions and 467 deletions
+18
View File
@@ -48,8 +48,26 @@ _Avoid_: thread, session (that names an AI session, a different thing), chat (th
One question and the answer to it: the run the question started, the handle that stops it,
and the rows it is writing. At most one per conversation, and the chat is held for its whole
length — from the moment the question takes the chat, before it has a job, until it is ended.
The server holds the same rule: a question sent to a conversation whose turn is still running
is refused, whoever sends it. Several conversations of one flow can each have a turn running.
_Avoid_: request, exchange, message round
**Running turn**:
A turn whose run has not finished. Which conversations have one is the server's to say, so a
chat opened after the turn started — a reload, another tab — still sees it and follows it.
_Avoid_: busy, active, in flight
**Queued message**:
A question typed into a conversation while its turn runs, sent when that turn ends answered.
At most one per conversation; typing another adds to it. A turn that fails, or is stopped,
hands it back to the composer instead of sending it.
_Avoid_: pending message (a pending message is one already sent and not yet confirmed)
**Unread**:
The answers that arrived in a conversation while it was not the one shown. Counted per open
chat and forgotten on reload.
_Avoid_: new messages, notifications
**Transcript**:
The rows a conversation's chat holds. Not the conversation: it is the newest page plus
whatever older pages the reader has scrolled back through, so a question it cannot answer
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT c.id AS \"id!\", u.job_id AS \"job_id!\", u.created_seq AS \"user_seq!\"\n FROM unnest($1::uuid[]) AS c(id)\n CROSS JOIN LATERAL (\n SELECT job_id, created_seq\n FROM flow_conversation_message\n WHERE conversation_id = c.id AND message_type = 'user'\n ORDER BY created_seq DESC\n LIMIT 1\n ) u\n WHERE u.job_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "job_id!",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "user_seq!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
null,
true,
false
]
},
"hash": "2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4"
}
@@ -0,0 +1,87 @@
//! A conversation answers one message at a time: while the run its newest user message started
//! is queued or running, the list reports that turn and a run into the conversation is refused.
//!
//! Uses runtime `sqlx::query` (not the compile-time macros) so no offline query cache is
//! needed, matching v2_job_delete_orphans.rs.
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::error::Error;
use windmill_common::flow_conversations::{get_or_create_conversation_with_id, running_turns};
use windmill_test_utils::*;
const WS: &str = "test-workspace";
const CONV: Uuid = Uuid::from_u128(0x5eed);
/// Takes the conversation for a turn, as a run does, without keeping what it writes.
async fn take_conversation(db: &Pool<Postgres>) -> windmill_common::error::Result<Uuid> {
let mut tx = db.begin().await?;
let result =
get_or_create_conversation_with_id(&mut tx, WS, "f/flow", "test-user", "t", CONV, false)
.await;
tx.rollback().await?;
result.map(|c| c.id)
}
#[sqlx::test(fixtures("base"))]
async fn test_a_conversation_refuses_a_turn_while_one_runs(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let job_id = Uuid::new_v4();
sqlx::query("INSERT INTO v2_job (id, workspace_id, kind) VALUES ($1, $2, 'flow')")
.bind(job_id)
.bind(WS)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, $2, now())",
)
.bind(job_id)
.bind(WS)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
VALUES ($1, $2, 'f/flow', 'test-user')",
)
.bind(CONV)
.bind(WS)
.execute(&db)
.await?;
let user_seq: i64 = sqlx::query_scalar(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'user', 'hi', $2) RETURNING created_seq",
)
.bind(CONV)
.bind(job_id)
.fetch_one(&db)
.await?;
let running = running_turns(&db, &[CONV]).await?;
let turn = running
.get(&CONV)
.expect("the queued run is the conversation's running turn");
assert_eq!((turn.job_id, turn.user_seq), (job_id, user_seq));
match take_conversation(&db).await {
Err(Error::Generic(status, body)) => {
assert_eq!(status.as_u16(), 409);
assert!(
body.contains(&job_id.to_string()),
"the refusal names the running job: {body}"
);
}
other => panic!("expected a 409 while the first turn runs, got {other:?}"),
}
// The run ends: it leaves the queue, and the conversation takes the next message.
sqlx::query("DELETE FROM v2_job_queue WHERE id = $1")
.bind(job_id)
.execute(&db)
.await?;
assert!(running_turns(&db, &[CONV]).await?.is_empty());
assert_eq!(take_conversation(&db).await?, CONV);
Ok(())
}
@@ -14,7 +14,7 @@ pub use windmill_common::flow_conversations::FlowConversation;
use windmill_common::{
db::{UserDB, DB},
error::{JsonResult, Result},
flow_conversations::MessageType,
flow_conversations::{running_turns, MessageType, RunningTurn},
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
};
@@ -81,7 +81,7 @@ async fn list_conversations(
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(query): Query<ListConversationsQuery>,
) -> JsonResult<Vec<FlowConversation>> {
) -> JsonResult<Vec<ListedConversation>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.clone().begin(&authed).await?;
@@ -123,9 +123,27 @@ async fn list_conversations(
let conversations = sqlx::query_as::<Postgres, FlowConversation>(&sql)
.fetch_all(&mut *tx)
.await?;
let ids: Vec<Uuid> = conversations.iter().map(|c| c.id).collect();
let mut running = running_turns(&mut *tx, &ids).await?;
tx.commit().await?;
Ok(Json(conversations))
Ok(Json(
conversations
.into_iter()
.map(|conversation| ListedConversation {
running_turn: running.remove(&conversation.id),
conversation,
})
.collect(),
))
}
#[derive(Serialize)]
pub struct ListedConversation {
#[serde(flatten)]
pub conversation: FlowConversation,
/// Lets a chat that opens after a turn started follow it, and tell which chats are busy.
pub running_turn: Option<RunningTurn>,
}
async fn delete_conversation(
+72
View File
@@ -11791,6 +11791,11 @@ paths:
content:
application/json:
schema: {}
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run_wait_result/fv/{version}:
post:
@@ -11831,6 +11836,11 @@ paths:
content:
application/json:
schema: {}
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
get:
summary: run flow by version with GET and wait until completion
@@ -11863,6 +11873,11 @@ paths:
content:
application/json:
schema: {}
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run_and_stream/f/{path}:
post:
@@ -11904,6 +11919,11 @@ paths:
text/event-stream:
schema:
type: string
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
get:
summary: run flow by path with GET and stream updates via SSE
@@ -11937,6 +11957,11 @@ paths:
text/event-stream:
schema:
type: string
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run_and_stream/fv/{version}:
post:
@@ -11984,6 +12009,11 @@ paths:
text/event-stream:
schema:
type: string
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
get:
summary: run flow by version with GET and stream updates via SSE
@@ -12023,6 +12053,11 @@ paths:
text/event-stream:
schema:
type: string
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run_and_stream/p/{path}:
post:
@@ -15432,6 +15467,11 @@ paths:
schema:
type: string
format: uuid
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run/fv/{version}:
post:
@@ -15489,6 +15529,11 @@ paths:
schema:
type: string
format: uuid
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run/batch_rerun_jobs:
post:
@@ -15982,6 +16027,11 @@ paths:
schema:
type: string
format: uuid
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run_wait_result/preview_flow:
post:
@@ -16011,6 +16061,11 @@ paths:
content:
application/json:
schema: {}
"409":
description: >-
Chat-enabled flow only: the conversation named by `memory_id` is still answering
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run/dynamic_select:
post:
@@ -28902,6 +28957,23 @@ components:
is_test:
type: boolean
description: Started from the flow editor's test panel rather than a deployed run
running_turn:
type: object
nullable: true
description: >-
The turn the conversation is still answering, set by the list endpoint: its
newest user message, while the flow run it started is queued or running. A
run into this conversation is refused with 409 until the turn ends.
required: [job_id, user_seq]
properties:
job_id:
type: string
format: uuid
description: The flow run of the turn
user_seq:
type: integer
format: int64
description: created_seq of the user message that started the turn
FlowConversationMessage:
type: object
+3 -1
View File
@@ -48,7 +48,9 @@ impl<B> OnResponse<B> for MyOnResponse {
let status = response.status().as_u16();
if response.status().is_success() || response.status().is_redirection() {
tracing::info!(latency = latency, status = status, "response")
} else if response.status().as_u16() == 404 {
} else if status == 404 || status == 409 {
// A refused turn is as expected as a miss: the flow chat takes the turn that
// refused it and sends its message after it.
tracing::warn!(latency = latency, status = status, "response")
} else {
tracing::error!(latency = latency, status = status, "response")
+5 -1
View File
@@ -303,7 +303,11 @@ impl IntoResponse for Error {
let e = &self;
if matches!(status, axum::http::StatusCode::NOT_FOUND) {
// A refused turn is as expected as a miss: the chat follows the turn that refused it.
if matches!(
status,
axum::http::StatusCode::NOT_FOUND | axum::http::StatusCode::CONFLICT
) {
tracing::warn!(message = e.to_string());
} else {
tracing::error!(message = e.to_string(), error = ?e);
@@ -69,7 +69,9 @@ pub async fn get_or_create_conversation_with_id(
is_test: bool,
) -> Result<FlowConversation> {
if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? {
return same_kind(existing, is_test);
let existing = same_kind(existing, is_test)?;
refuse_running_turn(tx, conversation_id).await?;
return Ok(existing);
}
// Truncate title to 25 characters max
@@ -104,7 +106,74 @@ pub async fn get_or_create_conversation_with_id(
"conversation {conversation_id} belongs to another workspace"
))
})?;
same_kind(existing, is_test)
let existing = same_kind(existing, is_test)?;
refuse_running_turn(tx, conversation_id).await?;
Ok(existing)
}
/// The turn a conversation is still answering: its newest user message, while the flow run
/// that message started is still queued or running.
#[derive(Serialize, Debug, Clone, Copy)]
pub struct RunningTurn {
pub job_id: Uuid,
/// `created_seq` of the user message that started the turn.
pub user_seq: i64,
}
/// One running turn per conversation holds its agent memory; a second run would write the
/// same memory concurrently. Checked under the conversation's row lock, so two runs sent at
/// once cannot both pass: the second waits, then sees the first's message and queued job.
async fn refuse_running_turn(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
conversation_id: Uuid,
) -> Result<()> {
let Some(turn) = running_turns(&mut **tx, &[conversation_id])
.await?
.remove(&conversation_id)
else {
return Ok(());
};
// A JSON body, so a chat client can follow the running turn instead of failing.
Err(crate::error::Error::Generic(
axum::http::StatusCode::CONFLICT,
serde_json::json!({
"error": "this conversation is still answering a message; wait for it to finish or stop it before sending another",
"running_turn": turn,
})
.to_string(),
))
}
/// The running turn of each of `conversation_ids` that has one.
///
/// It answers for whatever ids it is given and checks no permission of its own, so the
/// executor must be one the caller is entitled to read those conversations through: a
/// `user_db` transaction under RLS, or a transaction holding ids the caller has already
/// authorized. Handed a raw pool and ids from a request, it would report other users' jobs.
pub async fn running_turns<'e, E: sqlx::PgExecutor<'e>>(
executor: E,
conversation_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, RunningTurn>> {
let rows = sqlx::query!(
r#"SELECT c.id AS "id!", u.job_id AS "job_id!", u.created_seq AS "user_seq!"
FROM unnest($1::uuid[]) AS c(id)
CROSS JOIN LATERAL (
SELECT job_id, created_seq
FROM flow_conversation_message
WHERE conversation_id = c.id AND message_type = 'user'
ORDER BY created_seq DESC
LIMIT 1
) u
WHERE u.job_id IS NOT NULL
AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)"#,
conversation_ids
)
.fetch_all(executor)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.id, RunningTurn { job_id: r.job_id, user_seq: r.user_seq }))
.collect())
}
/// `memory_id` is the caller's to choose, so a preview run could name a deployed
+23 -1
View File
@@ -229,7 +229,8 @@ set) means the turn could not run or be followed at all, such as a refused reque
Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`,
`newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
`resumeTurn(turn)`, `refreshMessages()` (reads what another tab added to the open
conversation), `destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries
`isTest`. A rename keeps the conversation's place in the list. Switching conversations
stops following the current answer; the flow keeps running and, with server history,
@@ -261,6 +262,27 @@ user needs read and write on `windmill_uploads/*`, which the default rules grant
`job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot
request that scope today, so attachments are not available there yet.
## One turn at a time
A conversation answers one message at a time, and Windmill enforces it: a message sent
while its previous turn still runs (from another tab, or before a reload) is refused.
`sendMessage` then rejects with a `TurnRunningError` and shows nothing of the message.
With server history, a listed `Conversation` also carries `runningTurn` while it is
answering. Either way, `resumeTurn(turn)` follows that turn in the selected
conversation: its answer streams in from the start and the turn finishes as if it had
been sent here, after which the message can be sent again.
```ts
// `runningTurn` comes from the listing, so read it before selecting.
await chat.loadConversations()
const running = chat.getState().conversations.find((c) => c.id === id)?.runningTurn
await chat.selectConversation(id)
if (running) await chat.resumeTurn(running)
```
A dropped connection to the answer is retried; when it keeps failing, the chat stops
streaming and waits for the flow's result instead, so the turn still ends.
## History
Windmill stores every conversation of a chat-mode flow, and each Windmill user sees
+16 -1
View File
@@ -169,9 +169,19 @@ function chunkStream(
for (const e of event.events) parts.apply(e)
continue
}
// A result polled after the stream failed: the round that was streaming stopped
// wherever the connection did. Sent chunks cannot be taken back, so the answer's
// rest follows them, or the whole answer when it does not continue them.
const cut = parts.openText ?? ''
parts.closeOpen()
failure = await failureText(api, entry.jobId, event.result, signal)
if (failure === undefined && !parts.streamedText) {
if (failure === undefined && event.streamLost) {
const answer = extractChatAnswer(event.result)
if (answer !== undefined && answer !== cut) {
parts.text(answer.startsWith(cut) ? answer.slice(cut.length) : answer)
parts.closeOpen()
}
} else if (failure === undefined && !parts.streamedText) {
// No agent streamed: the flow's result is the answer.
const answer = extractChatAnswer(event.result)
if (answer !== undefined) {
@@ -216,6 +226,8 @@ async function failureText(
*/
class PartWriter {
streamedText = false
/** The text of the round still streaming, until a tool call or the end closes it. */
openText: string | undefined
#textId: string | undefined
#reasoningId: string | undefined
#started = new Set<string>()
@@ -227,8 +239,10 @@ class PartWriter {
this.streamedText = true
if (!this.#textId) {
this.#textId = randomId()
this.openText = ''
this.emit({ type: 'text-start', id: this.#textId })
}
this.openText += delta
this.emit({ type: 'text-delta', id: this.#textId, delta })
}
@@ -248,6 +262,7 @@ class PartWriter {
if (this.#textId) {
this.emit({ type: 'text-end', id: this.#textId })
this.#textId = undefined
this.openText = undefined
}
}
+64 -10
View File
@@ -13,13 +13,53 @@ export interface WindmillChatApiOptions {
export class WindmillApiError extends Error {
constructor(
message: string,
readonly status: number
readonly status: number,
/** The response body, as the server sent it. */
readonly body?: string
) {
super(message)
this.name = 'WindmillApiError'
}
}
/** The turn a conversation is still answering, as the server reports it. */
export interface RunningTurn {
jobId: string
/** `created_seq` of the user message that started the turn. */
userSeq: number
}
/**
* A message was sent to a conversation whose turn is still running. The server refuses
* it (409) so two runs never write one agent memory; `turn` is the run to follow instead.
*/
export class TurnRunningError extends Error {
constructor(
message: string,
readonly turn: RunningTurn
) {
super(message)
this.name = 'TurnRunningError'
}
}
/**
* The running turn named by a run's 409 body, or undefined for any other body. Exported
* for a custom `run` that calls Windmill through its own client: it rethrows the refusal
* as a `TurnRunningError` so the chat can follow the running turn.
*/
export function turnRunningError(body: string): TurnRunningError | undefined {
try {
const parsed = JSON.parse(body) as { error?: unknown; running_turn?: FlowConversation['running_turn'] }
const turn = parsed?.running_turn
if (!turn || typeof turn.job_id !== 'string' || typeof turn.user_seq !== 'number') return undefined
const message = typeof parsed.error === 'string' ? parsed.error : 'this conversation is still answering a message'
return new TurnRunningError(message, { jobId: turn.job_id, userSeq: turn.user_seq })
} catch {
return undefined
}
}
export interface FlowConversation {
id: string
workspace_id: string
@@ -30,6 +70,8 @@ export interface FlowConversation {
created_by: string
/** Started from the flow editor's test panel rather than a deployed run. */
is_test: boolean
/** Set by the list: the turn this conversation is still answering. */
running_turn?: { job_id: string; user_seq: number } | null
}
/**
@@ -111,18 +153,27 @@ export class WindmillChatApi {
this.#pollDelayMs = options.pollDelayMs
}
/** Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the job id. */
/**
* Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the
* job id. Throws `TurnRunningError` when the conversation is still answering.
*/
async runFlow(
flowPath: string,
args: Record<string, unknown>,
options: { memoryId: string; signal?: AbortSignal }
): Promise<string> {
const res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, {
method: 'POST',
query: { memory_id: options.memoryId, skip_preprocessor: 'true' },
body: args,
signal: options.signal
})
let res: Response
try {
res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, {
method: 'POST',
query: { memory_id: options.memoryId, skip_preprocessor: 'true' },
body: args,
signal: options.signal
})
} catch (e) {
if (e instanceof WindmillApiError && e.status === 409) throw turnRunningError(e.body ?? '') ?? e
throw e
}
return (await res.text()).trim()
}
@@ -146,7 +197,9 @@ export class WindmillChatApi {
signal: options.signal
})
if (!res.body) {
throw new WindmillApiError('The job update stream has no body', res.status)
// Status 0, whatever the status line said: a response with no stream in it comes from
// something in front of Windmill, so it is followed by a reconnect like any other.
throw new WindmillApiError('The job update stream has no body', 0)
}
for await (const data of readServerSentEvents(res.body)) {
try {
@@ -294,7 +347,8 @@ export class WindmillChatApi {
const text = await res.text().catch(() => '')
throw new WindmillApiError(
`${init.method ?? 'GET'} ${path} failed (${res.status})${text ? `: ${text}` : ''}`,
res.status
res.status,
text
)
}
return res
+297 -49
View File
@@ -1,9 +1,11 @@
import {
TurnRunningError,
WindmillApiError,
WindmillChatApi,
type ConversationKind,
type FlowConversation,
type FlowConversationMessage
type FlowConversationMessage,
type RunningTurn
} from './api'
import { resolveConfig, type ResolvedConfig } from './config'
import { followJob } from './follow'
@@ -38,6 +40,8 @@ const PERSIST_DEBOUNCE_MS = 250
/** Messages persist from spawned tasks that can land just after the flow completes. */
const RECONCILE_ATTEMPTS = 3
const RECONCILE_DELAY_MS = 400
/** Rows per read of what a turn wrote; a fuller page is read on from its last row. */
const ROWS_PAGE = 100
interface Turn {
controller: AbortController
@@ -73,6 +77,11 @@ class ChatImpl implements Chat {
/** The kind the caller last listed, so the refresh after a new turn lists the same rows. */
#conversationKind: ConversationKind | undefined
#persistTimer: ReturnType<typeof setTimeout> | undefined
/** Settles once the selected conversation's first page has been read. */
#selecting: Promise<void> = Promise.resolve()
/** Bumped whenever a turn is taken or the conversation is left: a read that started before
* it describes a conversation this chat has moved past. */
#epoch = 0
constructor(options: ChatOptions) {
this.#config = resolveConfig(options)
@@ -141,6 +150,7 @@ class ChatImpl implements Chat {
streamedText: false
}
this.#turn = turn
this.#epoch++
const timestamp = now()
const conversation: Conversation = this.#state.conversations.find(
@@ -186,6 +196,109 @@ class ChatImpl implements Chat {
turn.jobId = this.#config.run
? await this.#config.run(args, context)
: await this.#api.runFlow(this.#config.flowPath, args, context)
} catch (e) {
try {
// Nothing of this message reached the server, whether the conversation was already
// answering one sent elsewhere or an attachment never uploaded: the message is taken
// back rather than shown as a failed turn, and the caller gets the reason.
if (e instanceof TurnRunningError || !turn.started) {
this.#withdrawTurn(turn)
throw e
}
// stop() and a conversation switch abort the turn and settle the state themselves.
if (turn.controller.signal.aborted || isAbortError(e)) return
this.#failTurn(turn, e)
return
} finally {
if (this.#turn === turn) this.#turn = undefined
}
}
await this.#followTurn(turn, isNew)
}
resumeTurn = async ({ jobId, userSeq }: RunningTurn): Promise<void> => {
const conversationId = this.#state.conversationId
if (!conversationId || this.#turn) return
if (this.#state.history !== 'server') {
if (this.#state.status === 'submitted') this.#set({ status: 'idle' })
return
}
const turn: Turn = {
controller: new AbortController(),
conversationId,
userMessageId: '',
jobId,
// Its run was asked for elsewhere and is already going: there is nothing to withdraw,
// and the conversation it belongs to is listed.
isNew: false,
started: true,
withdrawn: false,
streamedText: false
}
this.#turn = turn
this.#epoch++
this.#set({ status: 'submitted', error: undefined })
try {
// The conversation's first page may still be on its way; it would land over the turn.
await this.#selecting
if (!this.#turnActive(turn)) return
// A listing is a snapshot: the turn it named can have ended and another one started
// since. The newest user message this chat holds names the turn to follow instead —
// following the one the listing named would drop the newer turn's rows and leave the
// chat idle while it runs.
const newest = this.#latestUserAfter(userSeq)
if (newest) {
if (!newest.jobId) {
// Its row is written and its run is not named yet: there is nothing to follow, so
// it is left to the next listing to name the turn that runs now.
if (this.#turn === turn) this.#turn = undefined
this.#set({ status: 'idle' })
return
}
turn.jobId = newest.jobId
userSeq = newest.seq!
}
// The stream replays the turn from its start, so what this chat shows of it goes and
// comes back as it replays: its rows, and what an earlier follow of it streamed or
// failed with, its own unread question included. The message that started it stays:
// it is the turn's anchor, and a long turn can have pushed it off the page this chat
// opened on. An earlier turn's answer that only its flow result gave has no row and so
// no seq; it names that turn's job, and it stays wherever the fallback put it.
const held = this.#state.messages
const at = held.findIndex((m) => m.seq === userSeq)
let user = at === -1 ? undefined : held[at]
let messages = held.filter((m, i) => {
if (m.seq !== undefined) return m.seq < userSeq
if (m.pending || m.jobId === turn.jobId) return false
return m.jobId !== undefined || (at !== -1 && i < at)
})
if (user) messages = [...messages, user]
if (!user) {
const [row] = await this.#api.listMessages(conversationId, {
afterSeq: userSeq - 1,
perPage: 1,
signal: turn.controller.signal
})
if (!this.#turnActive(turn)) return
if (row?.created_seq !== userSeq || row.message_type !== 'user') {
throw new Error('windmill-chat: the message that started the running turn is gone')
}
user = fromRow(row)
messages = [...messages, user]
}
turn.userMessageId = user.id
this.#set({ messages })
} catch (e) {
if (!(turn.controller.signal.aborted || isAbortError(e))) this.#failTurn(turn, e)
if (this.#turn === turn) this.#turn = undefined
return
}
await this.#followTurn(turn, false)
}
async #followTurn(turn: Turn, isNew: boolean): Promise<void> {
let nextTurn: RunningTurn | undefined
try {
const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {}
let result: unknown
try {
@@ -193,7 +306,7 @@ class ChatImpl implements Chat {
} finally {
stopPolling()
}
await this.#finishTurn(turn, result, isNew)
nextTurn = await this.#finishTurn(turn, result, isNew)
} catch (e) {
if (!turn.started) {
// Nothing ran: the message is withdrawn rather than shown as a failed turn, and the
@@ -208,6 +321,7 @@ class ChatImpl implements Chat {
} finally {
if (this.#turn === turn) this.#turn = undefined
}
if (nextTurn) await this.resumeTurn(nextTurn)
}
stop = async (): Promise<void> => {
@@ -249,8 +363,14 @@ class ChatImpl implements Chat {
})
}
selectConversation = async (conversationId: string): Promise<void> => {
if (conversationId === this.#state.conversationId) return
selectConversation = (conversationId: string): Promise<void> => {
if (conversationId === this.#state.conversationId) return this.#selecting
const selecting = this.#select(conversationId)
this.#selecting = selecting.catch(() => {})
return selecting
}
async #select(conversationId: string): Promise<void> {
this.#leaveConversation()
this.#page = 1
this.#set({
@@ -379,9 +499,19 @@ class ChatImpl implements Chat {
})
if (this.#state.conversationId !== conversationId) return
const known = new Set(this.#state.messages.map((m) => m.serverId ?? m.id))
// Pages count back from the newest row, so rows written since the first page shift
// newer rows into this one; and a resumed turn drops the rows it replays. Only what
// is older than everything held belongs above it.
const oldest = this.#state.messages.reduce<number | undefined>(
(min, m) => (m.seq !== undefined && (min === undefined || m.seq < min) ? m.seq : min),
undefined
)
const older = rows
.map(fromRow)
.filter((m) => !known.has(m.id) && (oldest === undefined || m.seq! < oldest))
this.#page = page
this.#set({
messages: [...rows.map(fromRow).filter((m) => !known.has(m.id)), ...this.#state.messages],
messages: [...older, ...this.#state.messages],
hasMoreMessages: rows.length === this.#config.pageSize
})
} finally {
@@ -389,6 +519,56 @@ class ChatImpl implements Chat {
}
}
refreshMessages = async (): Promise<void> => {
const conversationId = this.#state.conversationId
if (
!conversationId ||
this.#turn ||
this.#state.history !== 'server' ||
this.#state.loadingMessages
) {
return
}
const newestBefore = latestSeq(this.#state.messages)
const failure = lastFailureShown(this.#state.messages)
// A turn that starts and ends while these rows are read, or the conversation being left
// and opened again, leaves the chat holding messages this read knows nothing about, and
// the rows would land under them rather than in their own place. They are left to the
// next read, which sees the conversation as it is now.
const epoch = this.#epoch
await this.#syncFromServer(conversationId, () => this.#epoch === epoch)
if (this.#epoch !== epoch) return
if (!failure || this.#state.conversationId !== conversationId) return
if (!this.#state.messages.some((m) => m.id === failure.id)) return
// The turn this chat lost may have been carried to its end elsewhere, which only a row
// this read brought can say, and only one of that turn's own: an agent writes its answer
// from a task the run does not wait for, so an earlier turn's answer can commit after
// this turn's question and would otherwise settle it with someone else's answer. The
// failed turn's job is on the message it left; unknown jobs accept the row, as everywhere
// else the turn's jobs are read.
const jobs = failure.jobId ? await this.#turnJobIds(failure.jobId) : undefined
if (this.#state.conversationId !== conversationId) return
const held = this.#state.messages
if (!held.some((m) => m.id === failure.id)) return
const answered = held.some(
(m) =>
m.seq !== undefined &&
m.seq > newestBefore &&
m.role === 'assistant' &&
m.success &&
(jobs === undefined || m.jobId === undefined || jobs.has(m.jobId))
)
if (!answered) return
// A turn that ran, or failed, while those jobs were read owns the state now: this one's
// failure still goes, since its answer is here, but that turn's outcome stands.
const settles =
this.#state.status === 'error' && lastFailureShown(held)?.id === failure.id
this.#set({
messages: held.filter((m) => m.id !== failure.id),
...(settles ? { status: 'idle' as const, error: undefined } : {})
})
}
destroy = (): void => {
this.#leaveConversation()
}
@@ -398,7 +578,10 @@ class ChatImpl implements Chat {
async #follow(turn: Turn, onStreamStart: () => void): Promise<unknown> {
let started = false
for await (const event of followJob(this.#api, turn.jobId!, { signal: turn.controller.signal })) {
if (event.type === 'completed') return event.result
if (event.type === 'completed') {
if (event.streamLost) this.#dropCutRound(turn)
return event.result
}
if (!started) {
started = true
// Persisted rows for the streaming step would duplicate what is streaming.
@@ -409,6 +592,19 @@ class ChatImpl implements Chat {
throw new Error('windmill-chat: the job stream ended before the flow completed')
}
/**
* The stream failed while a round's text was arriving, so that text stops wherever the
* connection did. It goes: the persisted rows or, without them, the flow result give
* the whole answer instead. Rounds a tool call closed were complete and stay.
*/
#dropCutRound(turn: Turn): void {
if (!this.#turnActive(turn)) return
const cut = turn.assistantId
turn.assistantId = undefined
turn.streamedText = false
if (cut) this.#set({ messages: this.#state.messages.filter((m) => m.id !== cut) })
}
#applyEvents(turn: Turn, events: AgentStreamEvent[]): void {
if (events.length === 0 || !this.#turnActive(turn)) return
let messages = [...this.#state.messages]
@@ -506,18 +702,20 @@ class ChatImpl implements Chat {
this.#set({ messages, status: 'streaming' })
}
async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise<void> {
async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise<RunningTurn | undefined> {
if (!this.#turnActive(turn)) return
if (this.#state.history === 'server') {
turn.jobIds = await this.#turnJobIds(turn)
turn.jobIds = await this.#turnJobIds(turn.jobId!, turn.controller.signal)
if (!this.#turnActive(turn)) return
const reconciled = await this.#reconcileTurn(turn)
if (!this.#turnActive(turn)) return
if (reconciled) {
this.#set({ status: 'idle' })
const nextTurn = this.#nextTurnAfter(turn)
this.#set({ status: nextTurn ? 'submitted' : 'idle' })
this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages })
if (isNew) await this.loadConversations().catch(() => {})
return
if (!this.#turnActive(turn)) return
return nextTurn
}
// Server history just proved unreadable: the turn completes as local history.
}
@@ -540,9 +738,13 @@ class ChatImpl implements Chat {
messages = [...messages, assistantMessage(answer, true, turn.jobId)]
}
}
this.#set({ messages: finalized(messages), status: 'idle' })
const nextTurn = this.#state.history === 'server' ? this.#nextTurnAfter(turn) : undefined
this.#set({ messages: finalized(messages), status: nextTurn ? 'submitted' : 'idle' })
this.#persistLocal()
this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages })
// `onFinish` can switch conversation, and the next turn belongs to this one.
if (!this.#turnActive(turn)) return
return nextTurn
}
/**
@@ -561,11 +763,7 @@ class ChatImpl implements Chat {
for (let attempt = 1; attempt <= RECONCILE_ATTEMPTS; attempt++) {
let rows: FlowConversationMessage[]
try {
rows = await this.#api.listMessages(turn.conversationId, {
afterSeq: this.#lastSeq(),
perPage: 100,
signal: turn.controller.signal
})
rows = await this.#rowsAfterLastSeq(turn.conversationId, turn.controller.signal)
} catch (e) {
if (isAbortError(e)) throw e
if (this.#fallBackToLocal(e)) return false
@@ -592,22 +790,26 @@ class ChatImpl implements Chat {
* a badly delayed one can invert that order at the cost of the reconcile
* retries). The content is not compared with the flow result: an image answer, a
* structured one and a forwarded agent result are all persisted in a shape the
* result does not reproduce. Rows carrying a job id belong to the turn when the
* job is one of the turn's, which leaves out an earlier turn whose job outlived
* `stop()` (a token without `jobs:write` cannot cancel it); a tool row without one
* (an MCP call runs inside the agent step) belongs to whatever turn is under way.
* result does not reproduce. A row belongs to the turn when it was created after
* the user message and, when it carries a job id, the job is one of the turn's. The
* server refuses a turn while the previous run is queued, but an agent writes its
* answer row from a task the run does not wait for, so that row can still land after
* the next user message; its job says whose it is. A tool row without a job (an MCP
* call runs inside the agent step) belongs to the turn under way. Until the user
* message's own row has been read, its position in the list stands in for its seq.
*/
#answered(turn: Turn): boolean {
const messages = this.#state.messages
const from = messages.findIndex((m) => m.id === turn.userMessageId)
const userSeq = messages[from]?.seq
const ownJob = (m: ChatMessage) =>
turn.jobIds === undefined || (m.jobId === undefined ? m.role === 'tool' : turn.jobIds.has(m.jobId))
let latest: ChatMessage | undefined
for (let i = from + 1; i < messages.length; i++) {
const m = messages[i]
if (m.seq === undefined || m.role === 'user' || !ownJob(m)) continue
messages.forEach((m, i) => {
if (m.seq === undefined || m.role === 'user' || !ownJob(m)) return
if (userSeq !== undefined ? m.seq <= userSeq : i <= from) return
if (latest === undefined || m.seq > latest.seq!) latest = m
}
})
return latest?.role === 'assistant'
}
@@ -615,22 +817,29 @@ class ChatImpl implements Chat {
* The flow job plus every step job it ran, the failure and preprocessor steps
* included (a failure handler's answer is persisted under its own job), and the
* jobs an agent step's tool calls ran as (a tool row is persisted under its own
* job too). Unknown when the read fails.
* job too). A failed read is retried like the rows are; unknown when it keeps
* failing or the credential may not read jobs. Unknown accepts every row after the
* question: refusing them would leave a token without job access with no turn ever
* answered, each one finished a second time from its result.
*/
async #turnJobIds(turn: Turn): Promise<Set<string> | undefined> {
try {
const job = await this.#api.getFlowJob(turn.jobId!, turn.controller.signal)
const ids = new Set([turn.jobId!])
const status = job.flow_status
for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) {
if (m?.job) ids.add(m.job)
for (const j of m?.flow_jobs ?? []) ids.add(j)
for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id)
async #turnJobIds(jobId: string, signal?: AbortSignal): Promise<Set<string> | undefined> {
for (let attempt = 1; ; attempt++) {
try {
const job = await this.#api.getFlowJob(jobId, signal)
const ids = new Set([jobId])
const status = job.flow_status
for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) {
if (m?.job) ids.add(m.job)
for (const j of m?.flow_jobs ?? []) ids.add(j)
for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id)
}
return ids
} catch (e) {
if (isAbortError(e)) throw e
const refused = e instanceof WindmillApiError && e.status >= 400 && e.status < 500
if (refused || attempt === RECONCILE_ATTEMPTS) return undefined
await sleep(RECONCILE_DELAY_MS, signal)
}
return ids
} catch (e) {
if (isAbortError(e)) throw e
return undefined
}
}
@@ -689,11 +898,7 @@ class ChatImpl implements Chat {
}
if (stopped) return
try {
const rows = await this.#api.listMessages(turn.conversationId, {
afterSeq: this.#lastSeq(),
perPage: 100,
signal
})
const rows = await this.#rowsAfterLastSeq(turn.conversationId, signal)
if (!stopped && this.#turnActive(turn)) this.#mergeRows(rows)
} catch {
// transient; the completion reconciliation catches up
@@ -706,12 +911,22 @@ class ChatImpl implements Chat {
}
}
async #syncFromServer(conversationId: string): Promise<void> {
const rows = await this.#api.listMessages(conversationId, {
afterSeq: this.#lastSeq(),
perPage: 100
})
/** Every row created after the newest one held, however many pages that takes. */
async #rowsAfterLastSeq(conversationId: string, signal?: AbortSignal): Promise<FlowConversationMessage[]> {
const rows: FlowConversationMessage[] = []
let afterSeq = this.#lastSeq()
while (true) {
const page = await this.#api.listMessages(conversationId, { afterSeq, perPage: ROWS_PAGE, signal })
rows.push(...page)
if (page.length < ROWS_PAGE) return rows
afterSeq = page[page.length - 1].created_seq
}
}
async #syncFromServer(conversationId: string, stillCurrent?: () => boolean): Promise<void> {
const rows = await this.#rowsAfterLastSeq(conversationId)
if (this.#turn || this.#state.conversationId !== conversationId) return
if (stillCurrent && !stillCurrent()) return
this.#mergeRows(rows)
this.#set({ messages: finalized(this.#state.messages) })
}
@@ -828,6 +1043,21 @@ class ChatImpl implements Chat {
return this.#turn === turn && this.#state.conversationId === turn.conversationId
}
#latestUserAfter(userSeq: number): ChatMessage | undefined {
return this.#state.messages.reduce<ChatMessage | undefined>(
(found, message) =>
message.role === 'user' && message.seq !== undefined && message.seq > (found?.seq ?? userSeq) ? message : found,
undefined
)
}
#nextTurnAfter(turn: Turn): RunningTurn | undefined {
const userSeq = this.#state.messages.find((message) => message.id === turn.userMessageId)?.seq
if (userSeq === undefined) return undefined
const next = this.#latestUserAfter(userSeq)
return next?.jobId && next.seq !== undefined ? { jobId: next.jobId, userSeq: next.seq } : undefined
}
/** Stops following the current answer; the flow itself keeps running. */
#detachTurn(): void {
const turn = this.#turn
@@ -842,6 +1072,7 @@ class ChatImpl implements Chat {
* written out now rather than on the debounce that may never fire.
*/
#leaveConversation(): void {
this.#epoch++
if (this.#turn) {
this.#detachTurn()
this.#set({ messages: finalized(this.#state.messages), status: 'idle' })
@@ -859,6 +1090,20 @@ class ChatImpl implements Chat {
}
}
/** The failure this chat is showing: the message `#failTurn` left, which is never a row. */
function lastFailureShown(messages: readonly ChatMessage[]): ChatMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.seq === undefined && m.success === false) return m
}
return undefined
}
/** The newest row seq a list holds; 0 when it holds none. */
function latestSeq(messages: readonly ChatMessage[]): number {
return messages.reduce((newest, m) => (m.seq !== undefined && m.seq > newest ? m.seq : newest), 0)
}
function fromRow(row: FlowConversationMessage): ChatMessage {
const toolName =
row.message_type === 'tool'
@@ -898,7 +1143,10 @@ function fromConversation(row: FlowConversation): Conversation {
title: row.title ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
isTest: row.is_test
isTest: row.is_test,
runningTurn: row.running_turn
? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq }
: undefined
}
}
+86 -36
View File
@@ -1,13 +1,18 @@
import type { WindmillChatApi } from './api'
import { WindmillApiError, type WindmillChatApi } from './api'
import { createStreamEventParser, type AgentStreamEvent } from './stream'
import { abortError, sleep } from './utils'
import { abortError, isAbortError, sleep } from './utils'
const RECONNECT_DELAY_MS = 300
const MAX_RECONNECT_DELAY_MS = 5000
/** Consecutive failed connections before the job is polled instead. */
const MAX_CONNECTION_FAILURES = 3
const RESULT_POLL_MS = 2000
export type FollowEvent =
/** Agent events decoded from the job's result stream; empty when a chunk ended mid-line. */
| { type: 'stream'; events: AgentStreamEvent[] }
| { type: 'completed'; result: unknown }
/** `streamLost`: the result was polled after the stream failed, so what streamed may stop short. */
| { type: 'completed'; result: unknown; streamLost?: boolean }
/**
* Follows a job to completion across the server's stream timeouts: every
@@ -18,6 +23,10 @@ export type FollowEvent =
* The offset indexes the stream of one sub-job (`flow_stream_job_id`, the flow's
* streaming step). A retried step gets a new one, so when the id changes the
* offset is dropped and the connection reopened from that sub-job's start.
*
* A connection that fails (a proxy restarting, the network dropping) is retried with
* backoff. The run is still going, so after a few failures in a row the job's result is
* polled instead: the rest of the answer is not streamed, but the turn still ends.
*/
export async function* followJob(
api: WindmillChatApi,
@@ -27,45 +36,86 @@ export async function* followJob(
let parser = createStreamEventParser()
let offset = options.streamOffset
let streamJobId: string | undefined
while (true) {
let failures = 0
while (failures < MAX_CONNECTION_FAILURES) {
let reopen = false
for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) {
if (update.type === 'ping') continue
if (update.type === 'timeout') {
reopen = true
break
}
if (update.type === 'error') throw new Error(update.error)
if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`)
if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) {
const switched = streamJobId !== undefined && offset !== undefined
streamJobId = update.flow_stream_job_id
if (switched) {
// This connection skipped the new sub-job's first chunks: start it over.
offset = undefined
options.onOffset?.(undefined)
parser = createStreamEventParser()
try {
for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) {
// Opening a connection does not prove it is carrying the job. Only stream progress
// clears the count, or status snapshots followed by EOF would never reach polling.
if (update.type === 'ping') continue
if (update.type === 'timeout') {
reopen = true
break
}
if (update.type === 'error') throw new Error(update.error)
if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`)
if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) {
const switched = streamJobId !== undefined && offset !== undefined
streamJobId = update.flow_stream_job_id
if (switched) {
// This connection skipped the new sub-job's first chunks: start it over.
failures = 0
offset = undefined
options.onOffset?.(undefined)
parser = createStreamEventParser()
reopen = true
break
}
}
if (update.stream_offset !== undefined) {
if (update.stream_offset !== offset) failures = 0
offset = update.stream_offset
options.onOffset?.(offset)
}
if (update.new_result_stream) {
failures = 0
yield { type: 'stream', events: parser.push(update.new_result_stream) }
}
if (update.completed) {
const rest = parser.flush()
if (rest.length > 0) yield { type: 'stream', events: rest }
yield { type: 'completed', result: update.only_result }
return
}
}
if (update.stream_offset !== undefined) {
offset = update.stream_offset
options.onOffset?.(offset)
}
if (update.new_result_stream) {
yield { type: 'stream', events: parser.push(update.new_result_stream) }
}
if (update.completed) {
const rest = parser.flush()
if (rest.length > 0) yield { type: 'stream', events: rest }
yield { type: 'completed', result: update.only_result }
return
}
} catch (e) {
if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e
failures++
if (failures >= MAX_CONNECTION_FAILURES) break
await sleep(Math.min(RECONNECT_DELAY_MS * 2 ** failures, MAX_RECONNECT_DELAY_MS), options.signal)
continue
}
if (options.signal?.aborted) throw abortError()
// The server closes the connection after its timeout; a dropped connection looks
// the same minus the event. Either way the offset lets the next one resume.
if (!reopen) await sleep(RECONNECT_DELAY_MS, options.signal)
// The server closes the connection after its timeout; a connection that ends without
// that event, and without the job completing, carried nothing to its end. The offset
// lets the next one resume, and it counts like a failed one so a gateway closing every
// stream this way still reaches the polling below rather than reconnecting for ever.
if (!reopen) {
failures++
if (failures >= MAX_CONNECTION_FAILURES) break
await sleep(RECONNECT_DELAY_MS, options.signal)
}
}
while (true) {
await sleep(RESULT_POLL_MS, options.signal)
try {
const { completed, result } = await api.getCompletedResult(jobId, options.signal)
if (completed) {
yield { type: 'completed', result, streamLost: true }
return
}
} catch (e) {
if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e
}
}
}
/**
* A failure that says nothing about the job: the request never reached Windmill, or a
* gateway in front of it answered. A 4xx from Windmill itself (not found, refused) does.
*/
function isConnectionFailure(e: unknown): boolean {
if (e instanceof WindmillApiError) return e.status >= 500 || e.status === 0
return e instanceof TypeError
}
+3
View File
@@ -3,7 +3,10 @@ export { detectRawApp, type RawAppContext } from './config'
export {
WindmillChatApi,
WindmillApiError,
TurnRunningError,
turnRunningError,
readServerSentEvents,
type RunningTurn,
type WindmillChatApiOptions,
type ConversationKind,
type FlowConversation,
+5 -1
View File
@@ -6,6 +6,7 @@ export type UseWindmillChat = ChatState &
Pick<
Chat,
| 'sendMessage'
| 'resumeTurn'
| 'stop'
| 'newConversation'
| 'selectConversation'
@@ -13,6 +14,7 @@ export type UseWindmillChat = ChatState &
| 'deleteConversation'
| 'renameConversation'
| 'loadOlderMessages'
| 'refreshMessages'
> & { chat: Chat }
/**
@@ -61,13 +63,15 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
chat,
sendMessage: (text, options) =>
chat.sendMessage(text, { ...options, inputs: { ...latest.current.inputs, ...options?.inputs } }),
resumeTurn: chat.resumeTurn,
stop: chat.stop,
newConversation: chat.newConversation,
selectConversation: chat.selectConversation,
loadConversations: chat.loadConversations,
deleteConversation: chat.deleteConversation,
renameConversation: chat.renameConversation,
loadOlderMessages: chat.loadOlderMessages
loadOlderMessages: chat.loadOlderMessages,
refreshMessages: chat.refreshMessages
}),
[state, chat]
)
+21
View File
@@ -1,3 +1,5 @@
import type { RunningTurn } from './api'
export type ChatRole = 'user' | 'assistant' | 'tool' | 'system'
/**
@@ -61,6 +63,11 @@ export interface Conversation {
* server has listed the conversation; unset for one only this client has seen.
*/
isTest?: boolean
/**
* The turn the conversation was still answering when the server listed it: started in
* another tab, or before this chat was created. Pass it to `resumeTurn` to follow it.
*/
runningTurn?: RunningTurn
}
export interface ChatState {
@@ -164,8 +171,17 @@ export interface Chat {
* Sends a message in the current conversation, starting one when there is none. Resolves
* when the answer is complete. Rejects when the message could not be sent at all — a turn
* already running, an attachment that failed to upload — without touching the transcript.
* A conversation still answering a message sent elsewhere rejects with `TurnRunningError`:
* follow that turn with `resumeTurn`, then send again.
*/
sendMessage(text: string, options?: SendMessageOptions): Promise<void>
/**
* Follows a turn of the current conversation that this chat did not start, as named by
* `Conversation.runningTurn` or a `TurnRunningError`: its answer streams into `messages`
* from the start and the turn finishes like one sent here. Server history only; resolves
* when the answer is complete, at once when a turn is already being followed.
*/
resumeTurn(turn: RunningTurn): Promise<void>
/** Stops following the answer and asks Windmill to cancel the run. */
stop(): Promise<void>
newConversation(): void
@@ -183,6 +199,11 @@ export interface Chat {
/** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */
renameConversation(conversationId: string, title: string): Promise<void>
loadOlderMessages(): Promise<void>
/**
* Reads what the current conversation gained since its newest message held here, such
* as a turn another tab ran. Server history only; does nothing while a turn is followed.
*/
refreshMessages(): Promise<void>
/** Stops background work (stream, polling) and writes local history out. The chat stays usable. */
destroy(): void
}
+22
View File
@@ -59,4 +59,26 @@ describe('AI SDK Chat over the Windmill transport', () => {
expect(chat.error?.message).toBe('boom')
expect(memoryIds.length === 1 || calls.filter((c) => c.method === 'POST')[1].url.searchParams.get('memory_id') === memoryIds[0]).toBe(true)
})
test('a text part cut by a lost stream is completed from the polled result', async () => {
let streams = 0
const { fetch } = fetchMock(
(c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? text('job-1') : undefined),
(c) =>
c.url.pathname.endsWith('/getupdate_sse/job-1')
? ++streams === 1
? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'The ans' }), stream_offset: 1 }])
: text('bad gateway', 502)
: undefined,
(c) =>
c.url.pathname.endsWith('/get_result_maybe/job-1')
? json({ completed: true, success: true, result: { output: 'The answer is 42', messages: [] } })
: undefined
)
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, token: 'tok', fetch })
const chat = new Chat({ id: 'cut-chat', transport })
await chat.sendMessage({ text: 'what is it?' })
const texts = chat.messages[1].parts.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text)
expect(texts.join('')).toBe('The answer is 42')
}, 15000)
})
+801 -22
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { TurnRunningError } from '../src/api'
import { createChat } from '../src/chat'
import type { ChatOptions } from '../src/types'
import { fetchMock, json, memoryStorage, messageRow, ndjson, sse, sseTimed, text, type Route } from './support'
@@ -11,6 +12,15 @@ const run: Route = (c) =>
const streamPath = '/api/w/ws/jobs_u/getupdate_sse/job-1'
/** Waits for a condition the code under test must reach, and fails saying which one. */
async function until(done: () => boolean, what: string, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!done()) {
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`)
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
function options(extra: Partial<ChatOptions>, fetch: ChatOptions['fetch']): ChatOptions {
return { flowPath: FLOW, baseUrl: BASE, workspace: 'ws', fetch, storage: memoryStorage(), ...extra }
}
@@ -945,41 +955,752 @@ describe('createChat with server history', () => {
])
})
test('a late answer from a stopped job is not taken as the next turn answer', async () => {
let jobs = 0
let reads = 0
test('a message refused because a turn is running leaves nothing behind and names that turn', async () => {
const { fetch } = fetchMock(
(c) => (c.method === 'POST' && c.url.pathname.includes('/jobs/run/f/') ? text(`job-${++jobs}`) : undefined),
// job-1 never completes: the connection just ends, so the turn keeps waiting.
(c) => (c.url.pathname.endsWith('/getupdate_sse/job-1') ? sse([{ type: 'update' }]) : undefined),
(c) =>
c.url.pathname.endsWith('/getupdate_sse/job-2')
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? text(JSON.stringify({ error: 'still answering', running_turn: { job_id: 'job-9', user_seq: 41 } }), 409)
: undefined,
(c) => (c.url.pathname.endsWith('/messages') ? json([messageRow(40, 'user', 'earlier')]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
const refused = await chat.sendMessage('again').catch((e) => e)
expect(refused).toBeInstanceOf(TurnRunningError)
expect(refused.turn).toEqual({ jobId: 'job-9', userSeq: 41 })
const state = chat.getState()
expect(state.messages.map((m) => m.content)).toEqual(['earlier'])
expect(state.status).toBe('idle')
// The conversation is a real one, answering elsewhere: it stays listed, its message gone.
expect(state.conversations.map((c) => c.id)).toEqual(['conv'])
})
test('a listing that names a turn already over follows the one running now', async () => {
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2'
? sse([
{
type: 'update',
new_result_stream: ndjson({ type: 'token_delta', content: 'second answer' }),
stream_offset: 1,
completed: true,
only_result: { windmill_chat_answer: 'second answer' }
}
])
: undefined,
// The run-only token cannot cancel: job-1 keeps running after stop().
(c) => (c.url.pathname.includes('/queue/cancel/') ? text('forbidden', 400) : undefined),
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-2')
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
: undefined,
// Read 1 is stop()'s sync; the stopped job's answer lands after the second user row.
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const after = c.url.searchParams.get('after_seq')
if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })])
return json([
messageRow(50, 'user', 'first'),
messageRow(51, 'assistant', 'first answer'),
messageRow(52, 'user', 'second', { job_id: 'job-2' })
])
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
// The listing named the first turn; it ended and the second one started before this select.
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-1'))).toBe(false)
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true)
expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([
['row-50', 'first'],
['row-51', 'first answer'],
['row-52', 'second'],
['row-53', 'second answer']
])
expect(chat.getState().status).toBe('idle')
expect(chat.getState().error).toBeUndefined()
})
test('a newer message whose run is not named yet leaves the chat free', async () => {
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname.endsWith('/messages')
? json(
++reads === 1
? [messageRow(71, 'user', 'first')]
: reads === 2
? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })]
: [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })]
)
? json([messageRow(50, 'user', 'first'), messageRow(52, 'user', 'second')])
: undefined
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().status).toBe('idle')
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse'))).toBe(false)
// A message sent now starts its own turn rather than being refused.
expect(chat.getState().error).toBeUndefined()
})
test('a turn started while a resumed turn reconciles is followed next', async () => {
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) =>
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2'
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-2')
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const after = c.url.searchParams.get('after_seq')
if (after === null) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })])
if (after === '50') {
return json([
messageRow(51, 'assistant', 'first answer', { job_id: 'step-1' }),
messageRow(52, 'user', 'second', { job_id: 'job-2' })
])
}
if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })])
return json([])
}
)
const chat = createChat(options({}, fetch))
const statuses: string[] = []
chat.subscribe((state) => statuses.push(state.status))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(calls.some((c) => c.url.pathname === streamPath)).toBe(true)
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true)
expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([
['row-50', 'first'],
['row-51', 'first answer'],
['row-52', 'second'],
['row-53', 'second answer']
])
expect(chat.getState().status).toBe('idle')
// Vacuous without this: with no 'submitted' at all, both lookups are -1 and the slice empty.
expect(statuses).toContain('submitted')
expect(statuses.slice(statuses.indexOf('submitted'), statuses.lastIndexOf('submitted') + 1)).not.toContain('idle')
})
test('a fallback to local history during handoff does not leave the chat busy', async () => {
let messageFetches = 0
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
messageFetches++
if (messageFetches === 1) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })])
if (messageFetches === 2) return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })])
return text('forbidden', 403)
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().history).toBe('local')
expect(chat.getState().status).toBe('idle')
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
})
test('a conversation list fallback during handoff does not leave the chat busy', async () => {
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
: undefined,
(c) =>
c.url.pathname.endsWith('/messages')
? json([
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }),
messageRow(52, 'user', 'second', { job_id: 'job-2' })
])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? text('forbidden', 403) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.sendMessage('first')
expect(chat.getState().history).toBe('local')
expect(chat.getState().status).toBe('idle')
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
})
test('a conversation switch during handoff does not resume the next turn elsewhere', async () => {
let releaseList!: (response: Response) => void
const listGate = new Promise<Response>((resolve) => {
releaseList = resolve
})
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([])
return json([
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }),
messageRow(52, 'user', 'second', { job_id: 'job-2' })
])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined)
)
const chat = createChat(options({}, fetch))
const sent = chat.sendMessage('first')
await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for')
await chat.selectConversation('other')
releaseList(json([]))
await sent
expect(chat.getState().conversationId).toBe('other')
expect(chat.getState().status).toBe('idle')
expect(chat.getState().messages).toEqual([])
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
})
test('resuming a later turn keeps an earlier answer that only the flow result gave', async () => {
const { fetch } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) =>
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2'
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-2')
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const after = c.url.searchParams.get('after_seq')
if (after === '51') return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })])
if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })])
// The first answer never got a row: the turn finishes from the flow result.
return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.sendMessage('first')
await chat.resumeTurn({ jobId: 'job-2', userSeq: 52 })
expect(chat.getState().messages.map((m) => m.content)).toEqual([
'first',
'first answer',
'second',
'second answer'
])
})
test('resuming a turn this chat lost drops what it had shown of that turn', async () => {
let streams = 0
const { fetch } = fetchMock(
(c) => {
if (c.url.pathname !== streamPath) return undefined
return ++streams === 1
? sse([
{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 },
{ type: 'error', error: 'stream broke' }
])
: sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }])
},
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.searchParams.get('after_seq') === '50') {
return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })])
}
return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().status).toBe('error')
// The run goes on; following it again replays the whole answer under its question.
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer'])
})
test('resuming a turn this chat sent and lost shows its question once', async () => {
let streams = 0
const { fetch } = fetchMock(
run,
(c) => {
if (c.url.pathname !== streamPath) return undefined
return ++streams === 1
? sse([
{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 },
{ type: 'error', error: 'stream broke' }
])
: sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }])
},
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const after = c.url.searchParams.get('after_seq')
if (after === '49') return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
if (after === '50') return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })])
// The stream fails before the question's row is read.
return json([])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.sendMessage('question')
expect(chat.getState().status).toBe('error')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer'])
})
test('a re-read that finds the lost turn answered clears the failure it was left with', async () => {
let answered = false
const { fetch } = fetchMock(
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'error', error: 'stream broke' }])
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const rows = [messageRow(50, 'user', 'question', { job_id: 'job-1' })]
if (answered) rows.push(messageRow(51, 'assistant', 'the answer', { job_id: 'job-1' }))
return json(rows)
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().status).toBe('error')
// Another tab carried the turn to its end and its answer is in the rows now.
answered = true
await chat.refreshMessages()
// The answer stands alone: the failure this chat showed was never a row.
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer'])
expect(chat.getState().status).toBe('idle')
expect(chat.getState().error).toBeUndefined()
})
test("a re-read does not settle a failed turn with the previous turn's late answer", async () => {
const { fetch } = fetchMock(
(c) =>
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-b'
? sse([{ type: 'error', error: 'stream broke' }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-b')
? json({ flow_status: { modules: [{ job: 'step-b' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const rows = [
messageRow(50, 'user', 'qA', { job_id: 'job-a' }),
messageRow(51, 'user', 'qB', { job_id: 'job-b' })
]
// Turn A's answer commits from its detached task, after B's question.
if (c.url.searchParams.get('after_seq') === '51') {
return json([messageRow(52, 'assistant', "A's answer", { job_id: 'step-a' })])
}
return json(rows)
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-b', userSeq: 51 })
expect(chat.getState().status).toBe('error')
await chat.refreshMessages()
// A's answer is not B's: B's failure stands, and it is still shown.
expect(chat.getState().status).toBe('error')
expect(chat.getState().messages.some((m) => m.success === false && m.seq === undefined)).toBe(true)
})
test("a re-read settles the failed turn from its own answer, not only from the newest row", async () => {
const { fetch } = fetchMock(
(c) =>
c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.searchParams.get('after_seq') === '50') {
// The turn's answer, and behind it the question of a turn started elsewhere.
return json([
messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' }),
messageRow(52, 'user', 'next question', { job_id: 'job-2' })
])
}
return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().status).toBe('error')
await chat.refreshMessages()
expect(chat.getState().status).toBe('idle')
expect(chat.getState().messages.map((m) => m.content)).toEqual([
'question',
'the answer',
'next question'
])
})
test('a conversation left and opened again while its rows are read drops that read', async () => {
let releaseRows!: (r: Response) => void
const rowsGate = new Promise<Response>((resolve) => (releaseRows = resolve))
let reads = 0
const { fetch } = fetchMock((c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.pathname.includes('/flow_conversations/other/')) return json([])
reads++
// The read the refresh made, answered only after the reader has come back.
if (reads === 2) return rowsGate
return json([messageRow(150, 'user', 'newest page', { job_id: 'job-x' })])
})
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
const refreshed = chat.refreshMessages()
await chat.selectConversation('other')
await chat.selectConversation('conv')
// Rows from before the reader left: older than the page the chat holds now.
releaseRows(json([messageRow(51, 'user', 'older page', { job_id: 'job-y' })]))
await refreshed
expect(chat.getState().messages.map((m) => m.content)).toEqual(['newest page'])
})
test('a turn that runs while the rows are read leaves them to the next read', async () => {
let releaseRows!: (r: Response) => void
const rowsGate = new Promise<Response>((resolve) => (releaseRows = resolve))
let reads = 0
const { fetch } = fetchMock(
run,
(c) =>
c.url.pathname.includes('/jobs_u/getupdate_sse/')
? sse([{ type: 'error', error: 'stream broke' }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (++reads === 1) return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })])
// The read the refresh made, answered only after another turn has come and gone.
if (reads === 2) return rowsGate
return json([])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
const refreshed = chat.refreshMessages()
// A turn starts and fails entirely while that read is in flight.
await chat.sendMessage('qB')
expect(chat.getState().status).toBe('error')
releaseRows(json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })]))
await refreshed
// A's answer is not appended under B: it waits for a read that sees the conversation as
// it is now.
expect(chat.getState().messages.map((m) => m.content)).toEqual([
'qA',
'qB',
'stream broke'
])
})
test('a turn that starts while the jobs are read still frees the answered failure', async () => {
let releaseJobs!: (r: Response) => void
const jobsGate = new Promise<Response>((resolve) => (releaseJobs = resolve))
let releaseRun!: (r: Response) => void
const runGate = new Promise<Response>((resolve) => (releaseRun = resolve))
const { fetch, calls } = fetchMock(
(c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? runGate : undefined),
(c) =>
c.url.pathname.includes('/jobs_u/getupdate_sse/')
? sse([{ type: 'error', error: 'stream broke' }])
: undefined,
(c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined),
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-b')
? json({ flow_status: { modules: [{ job: 'job-b' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.searchParams.get('after_seq') === '50') {
return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })])
}
return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 })
expect(chat.getState().status).toBe('error')
const refreshed = chat.refreshMessages()
await until(
() => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')),
'the job read to start'
)
// A turn starts while that read is in flight, and is still running when it answers.
const sent = chat.sendMessage('qB')
await until(() => chat.getState().status === 'submitted', 'the new turn to take the chat')
releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } }))
await refreshed
// A's failure goes with its answer; the running turn keeps the chat busy.
expect(chat.getState().messages.some((m) => m.success === false)).toBe(false)
expect(chat.getState().status).toBe('submitted')
releaseRun(text('job-b'))
await sent
})
test('a turn that fails while the jobs are read keeps its own failure', async () => {
let releaseJobs!: (r: Response) => void
const jobsGate = new Promise<Response>((resolve) => (releaseJobs = resolve))
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath || c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1'
? sse([{ type: 'error', error: 'stream broke' }])
: undefined,
(c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined),
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.searchParams.get('after_seq') === '50') {
return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })])
}
return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 })
expect(chat.getState().status).toBe('error')
// The refresh stops on the job read; a second turn is sent and fails while it waits.
const refreshed = chat.refreshMessages()
await until(
() => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')),
'the job read to start'
)
await chat.sendMessage('qB')
expect(chat.getState().status).toBe('error')
releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } }))
await refreshed
// A's failure goes, since its answer is here; the error the chat shows is B's.
expect(chat.getState().status).toBe('error')
const failures = chat.getState().messages.filter((m) => m.success === false)
expect(failures).toHaveLength(1)
expect(chat.getState().messages.map((m) => m.content)).toContain("A's answer")
})
test('a re-read that finds no answer leaves the failure standing', async () => {
const { fetch } = fetchMock(
(c) =>
c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined,
(c) =>
c.url.pathname.endsWith('/messages')
? json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
: undefined
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().status).toBe('error')
await chat.refreshMessages()
expect(chat.getState().status).toBe('error')
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'stream broke'])
})
test('a conversation switch from onFinish does not resume the next turn elsewhere', async () => {
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([])
// No answer row lands, so the turn finishes from the flow result.
return json([
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
messageRow(52, 'user', 'second', { job_id: 'job-2' })
])
},
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({ onFinish: () => void chat.selectConversation('other') }, fetch))
await chat.sendMessage('first')
expect(chat.getState().conversationId).toBe('other')
expect(chat.getState().messages).toEqual([])
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
})
test('stopping during handoff does not resume the next turn', async () => {
let releaseList!: (response: Response) => void
const listGate = new Promise<Response>((resolve) => {
releaseList = resolve
})
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
: undefined,
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
: undefined,
(c) =>
c.url.pathname.endsWith('/messages')
? json([
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }),
messageRow(52, 'user', 'second', { job_id: 'job-2' })
])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined)
)
const chat = createChat(options({}, fetch))
const sent = chat.sendMessage('first')
await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for')
const stopped = chat.stop()
releaseList(json([]))
await Promise.all([sent, stopped])
expect(chat.getState().status).toBe('idle')
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
})
test('a stream that keeps ending before the job completes hands the turn to polling', async () => {
let streams = 0
const { fetch } = fetchMock(
run,
// Every real connection starts with a status snapshot. It is not stream progress.
(c) => (c.url.pathname === streamPath ? (streams++, sse([{ type: 'update', running: true }])) : undefined),
(c) =>
c.url.pathname.endsWith('/get_result_maybe/job-1')
? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } })
: undefined,
(c) =>
c.url.pathname.endsWith('/messages')
? json([messageRow(81, 'user', 'hi'), messageRow(82, 'assistant', 'polled')])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
const first = chat.sendMessage('first')
await new Promise((r) => setTimeout(r, 50))
await chat.stop()
await first
await chat.sendMessage('hi')
expect(streams).toBe(3)
expect(chat.getState().status).toBe('idle')
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'polled'])
}, 15000)
test('resuming a turn whose message is off the first page replays it without duplicating rows', async () => {
const { fetch } = fetchMock(
(c) =>
c.url.pathname === streamPath && !c.url.searchParams.has('stream_offset')
? sse([
{
type: 'update',
new_result_stream: ndjson(
{ type: 'tool_call', call_id: 'c1', function_name: 'lookup' },
{ type: 'tool_result', call_id: 'c1', function_name: 'lookup', result: '1', success: true },
{ type: 'token_delta', content: 'Done' }
),
stream_offset: 3,
completed: true,
only_result: { output: 'Done', messages: [] }
}
])
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
const after = c.url.searchParams.get('after_seq')
// The first page holds only what the running turn wrote so far.
if (after === null) return json([messageRow(51, 'tool', 'Used lookup tool')])
if (after === '49') return json([messageRow(50, 'user', 'hi')])
return json([messageRow(51, 'tool', 'Used lookup tool'), messageRow(52, 'assistant', 'Done')])
}
)
const chat = createChat(options({}, fetch))
void chat.selectConversation('conv')
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
expect(chat.getState().messages.map((m) => [m.serverId, m.role, m.content, m.pending])).toEqual([
['row-50', 'user', 'hi', false],
['row-51', 'tool', 'Used lookup tool', false],
['row-52', 'assistant', 'Done', false]
])
expect(chat.getState().status).toBe('idle')
})
test("the previous run's answer landing after the next message is not that turn's answer", async () => {
let reads = 0
let jobReads = 0
const { fetch } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
: undefined,
// A transient failure of the job read is retried, not taken as "any row counts".
(c) =>
c.url.pathname.endsWith('/jobs_u/get/job-1')
? ++jobReads === 1
? text('bad gateway', 502)
: json({ flow_status: { modules: [{ job: 'step-2' }] } })
: undefined,
(c) => {
if (!c.url.pathname.endsWith('/messages')) return undefined
if (!c.url.searchParams.has('after_seq')) return json([messageRow(71, 'user', 'first')])
// The earlier agent wrote its answer from a task its run did not wait for.
return json(
++reads === 1
? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })]
: [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })]
)
}
)
const chat = createChat(options({}, fetch))
await chat.selectConversation('conv')
await chat.sendMessage('second')
expect(chat.getState().messages.map((m) => [m.role, m.content, m.serverId])).toEqual([
['user', 'first', 'row-71'],
@@ -989,6 +1710,64 @@ describe('createChat with server history', () => {
])
})
test('an answer cut by a lost stream gives way to the polled result', async () => {
let streams = 0
const { fetch } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? ++streams === 1
? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'Hel' }), stream_offset: 1 }])
: text('bad gateway', 502)
: undefined,
(c) =>
c.url.pathname.endsWith('/get_result_maybe/job-1')
? json({ completed: true, success: true, result: { windmill_chat_answer: 'Hello, full answer' } })
: undefined
)
const chat = createChat(options({ history: 'none' }, fetch))
await chat.sendMessage('hi')
expect(chat.getState().messages.map((m) => [m.role, m.content, m.pending])).toEqual([
['user', 'hi', false],
['assistant', 'Hello, full answer', false]
])
}, 15000)
test('a stream that keeps failing hands the turn to polling the job', async () => {
// Each connection opens, sends the server's ping, then drops.
const pingThenDrop = () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'ping' })}\n\n`))
setTimeout(() => controller.error(new TypeError('network connection was lost')), 20)
}
}),
{ status: 200, headers: { 'content-type': 'text/event-stream' } }
)
const { fetch } = fetchMock(
run,
(c) => (c.url.pathname === streamPath ? pingThenDrop() : undefined),
(c) =>
c.url.pathname.endsWith('/get_result_maybe/job-1')
? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } })
: undefined,
(c) =>
c.url.pathname.endsWith('/messages')
? json([messageRow(61, 'user', 'hi'), messageRow(62, 'assistant', 'polled')])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.sendMessage('hi')
const state = chat.getState()
expect(state.status).toBe('idle')
expect(state.messages.map((m) => [m.role, m.content, m.success])).toEqual([
['user', 'hi', true],
['assistant', 'polled', true]
])
}, 15000)
test('a failure handler answer is attributed to the turn', async () => {
let reads = 0
const { fetch } = fetchMock(
+17
View File
@@ -23,3 +23,20 @@ test('a retried streaming step reports its offset as lost before the new sub-job
}
expect(offsets).toEqual([3, undefined, 1])
})
test('a 200 carrying no stream is reconnected like any other failed connection', async () => {
let streams = 0
const { fetch } = fetchMock((c) => {
if (!c.url.pathname.endsWith('/jobs_u/getupdate_sse/job-1')) return undefined
// Something in front of Windmill answers 200 with nothing in it.
if (++streams === 1) return new Response(null, { status: 200 })
return sse([{ type: 'update', completed: true, only_result: 'ok' }])
})
const api = new WindmillChatApi({ baseUrl: 'http://wm.test', workspace: 'ws', token: 'tok', fetch })
let result: unknown
for await (const event of followJob(api, 'job-1')) {
if (event.type === 'completed') result = event.result
}
expect(streams).toBe(2)
expect(result).toBe('ok')
})
@@ -1,5 +1,6 @@
<script lang="ts">
import {
ApiError,
type Job,
JobService,
type RestartedFrom,
@@ -216,9 +217,12 @@
}
onRunPreview?.(newJobId)
} catch (e) {
sendUserToast('Could not run preview', true, undefined, e.toString())
isRunning = false
jobId = undefined
// The chat follows the turn its conversation is still answering rather than
// reporting a failed run, so the refusal goes back to it.
if (conversationId && e instanceof ApiError && e.status === 409) throw e
sendUserToast('Could not run preview', true, undefined, e.toString())
}
schemaFormWithArgPicker?.refreshHistory()
return newJobId
@@ -465,12 +469,14 @@
<div class="flex flex-row justify-center w-full mb-6">
<FlowChat
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
await runPreview(
// Its own run's id, not `jobId`: several test chats can start a run at
// once, and `jobId` is whichever started last.
const started = await runPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
undefined,
conversationId
)
return jobId ?? ''
return started ?? ''
}}
conversationKind="test"
frame="boxed"
@@ -0,0 +1,54 @@
<script lang="ts">
/**
* How many messages have arrived somewhere since it was last read.
*
* Sits in the row's own flow by default, which is where a list uses it; `class` pins it
* to a corner for a caller that has one icon standing for the whole list. Nothing renders
* at zero — an absent badge is what "nothing new" looks like.
*/
import { twMerge } from 'tailwind-merge'
interface Props {
count: number
/** What the count is of, for the label a screen reader reads. */
noun?: string
/** Positioning for a caller that pins it to a corner rather than letting it sit in
* the row — the collapsed rail, where the count belongs to an icon button. */
class?: string
/** The 12px form, for a corner where the row-sized badge would crowd the icon. */
small?: boolean
}
let { count, noun = 'message', class: className = '', small = false }: Props = $props()
</script>
{#if count > 0}
<span
class={twMerge(
'unread-badge inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium',
small ? 'min-w-3 h-3 px-0.5 text-[8px]' : 'min-w-3.5 h-3.5 px-1 text-[9px]',
className,
// Keep this last: tailwind-merge counts a text size as resetting line-height,
// including a size supplied by a caller.
'leading-none'
)}
role="img"
aria-label="{count} unread {noun}{count === 1 ? '' : 's'}"
>
{count > 9 ? '9+' : count}
</span>
{/if}
<style>
/*
* Centring a digit by flex centres its *line box*, which is the font's em box — and a
* digit's ink does not sit in the middle of that. Inter reserves descender space a
* figure never uses, so the glyph lands fractionally low; at these sizes that reads as
* the badge being a pixel off. Trimming the box to cap-height and baseline makes the
* ink itself what gets centred. Dropped silently where it is unsupported, which leaves
* the same near-miss as before rather than anything worse.
*/
.unread-badge {
text-box: trim-both cap alphabetic;
}
</style>
@@ -247,8 +247,16 @@
function onWindowKeydownCapture(e: KeyboardEvent) {
if (e.key !== 'Escape' || !chatHost.loading) return
const active = document.activeElement
// Focus parked on the body answers for the panel on screen only: the flow chat keeps
// a panel per conversation mounted, several of which can be loading, and a hidden
// one's listener would otherwise swallow the press (immediate form, below) and stop
// its own turn instead. `inert` marks the panels behind, and is matched as an
// attribute so the browsers without `checkVisibility` read it too.
const hidden =
panelEl?.closest('[inert]') != null ||
panelEl?.checkVisibility?.({ visibilityProperty: true }) === false
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
!hidden && (!active || active === document.body || panelEl?.contains(active) === true)
// An Escape while a run form is open must not discard what the user typed, so the action
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
@@ -487,22 +495,30 @@
const flatFiles = Array.from(dt.files ?? [])
const topLevelImages = flatFiles.filter(isImageFile)
const imageWork: Promise<unknown>[] = []
// The composer this drop landed on, held for the whole routing: a panel destroyed
// while a file is being read clears the binding, and the file would then reach no
// composer at all.
const dropped = aiChatInput
if (topLevelImages.length > 0) {
imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve())
imageWork.push(dropped?.addImages(topLevelImages) ?? Promise.resolve())
}
// Text-file routing must await handle/entry resolution before it can call
// addTextFiles — hold sending across that window (taken BEFORE the first
// await) or a send mid-resolution would land the drop on the next message.
const releaseSendHold = aiChatInput?.holdSendForIngestion()
const releaseSendHold = dropped?.holdSendForIngestion()
try {
await routeDroppedTextAndFolders(dt, flatFiles)
await routeDroppedTextAndFolders(dt, flatFiles, dropped)
} finally {
releaseSendHold?.()
}
await Promise.all(imageWork)
}
async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) {
async function routeDroppedTextAndFolders(
dt: DataTransfer,
flatFiles: File[],
input: typeof aiChatInput
) {
if (canUseFsAccess) {
// getAsFileSystemHandle calls are kicked off synchronously inside this call.
const handles = await handlesFromDataTransfer(dt)
@@ -514,7 +530,10 @@
? flatFiles
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
// Loose files attach to the message, like images.
await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f)))
await attachNonImageFiles(
looseFiles.filter((f) => !isImageFile(f)),
input
)
// Folders link as a live handle.
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
@@ -551,7 +570,7 @@
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
await attachNonImageFiles(topLevelText)
await attachNonImageFiles(topLevelText, input)
}
}
@@ -563,8 +582,10 @@
input.value = '' // allow re-selecting the same file
}
async function attachNonImageFiles(files: File[]) {
await aiChatInput?.addNonImageFiles(files)
/** `input` is the composer the files are for, captured before any await: read off the
* binding afterwards it would be null once the panel has been destroyed. */
async function attachNonImageFiles(files: File[], input: typeof aiChatInput) {
await input?.addNonImageFiles(files)
}
async function attachPickedFiles(picked: File[]) {
@@ -572,7 +593,7 @@
const others = picked.filter((f) => !isImageFile(f))
// Reserved before the other work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
await attachNonImageFiles(others)
await attachNonImageFiles(others, aiChatInput)
await imageWork
}
@@ -2,7 +2,7 @@
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, tick, untrack } from 'svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
import type { InputTransform } from '$lib/gen'
import { ApiError, type InputTransform } from '$lib/gen'
import type { FlowAIChatHelpers } from './core'
import { chatMemoryId } from '../global/core'
import { createInlineScriptSession } from './inlineScriptsUtils'
@@ -174,7 +174,19 @@
previewArgs.val = args
}
// Call the UI test function which opens preview panel
return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value))
try {
return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value))
} catch (e) {
// The flow chat is still answering in that conversation, and the server takes one
// turn at a time. Said plainly here: the raw refusal names a job the model has
// no use for.
if (e instanceof ApiError && e.status === 409) {
throw new Error(
'That chat is still answering an earlier message; wait for it to finish before testing again.'
)
}
throw e
}
},
getLintErrors: async (moduleId: string): Promise<ScriptLintResult> => {
@@ -1,13 +1,22 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { createChat, type Chat, type ChatState } from 'windmill-chat'
import {
createChat,
turnRunningError,
WindmillChatApi,
type Chat,
type ChatOptions,
type ChatState
} from 'windmill-chat'
import FlowConversationsSidebar from './FlowConversationsSidebar.svelte'
import FlowChatInterface from './FlowChatInterface.svelte'
import { getContext } from 'svelte'
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from '../types'
import type { FlowModule } from '$lib/gen'
import { FRAME_CLASS, type ChatFrame } from './flowChatProps'
import { ApiError, type FlowModule } from '$lib/gen'
import { FlowChatPool, type FlowChatPoolState } from './flowChatPool'
import { FlowChatViewHost, type ComposerAttachment } from './flowChatViewHost.svelte'
import { FRAME_CLASS, loadFlowChatInputs, type ChatFrame } from './flowChatProps'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -73,24 +82,51 @@
// The editor may act on a workspace other than the nav store's (AI-session live editor).
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $operatingWorkspace)
let chat = $state<Chat | undefined>(undefined)
let chatState = $state<ChatState | undefined>(undefined)
// The sidebar lists, renames and deletes through `listChat`; each conversation runs its
// turns on its own chat in the pool, so several can answer at once.
let listChat = $state<Chat | undefined>(undefined)
let listState = $state<ChatState | undefined>(undefined)
let pool = $state<FlowChatPool<FlowChatViewHost, ComposerAttachment> | undefined>(undefined)
let poolState = $state<FlowChatPoolState | undefined>(undefined)
let sidebar = $state<FlowConversationsSidebar | undefined>(undefined)
/** Each conversation's kind as a listing gave it, kept past a filter that stops listing it. */
const conversationKinds = new Map<string, boolean>()
// What the reader chose for this flow — the model among them — held here rather than in
// each panel, so every conversation's composer sends and shows the same values. Read again
// wherever the chat is rebuilt below, since this component is reused from one flow to the
// next and would otherwise send one flow's settings with another's messages.
let inputValues = $state<Record<string, any>>({})
$effect(() => {
const ws = workspace
const flowPath = path
if (!ws || !flowPath) return
const created = createChat({
// Untracked: the identity only names what the path already changed, and reading it here
// would rebuild every chat of a flow whose identity merely arrived late.
inputValues = loadFlowChatInputs({ path: flowPath, identity: untrack(() => identity) })
conversationKinds.clear()
const baseUrl = window.location.origin
const options: ChatOptions = {
flowPath,
workspace: ws,
baseUrl: window.location.origin,
baseUrl,
history: 'server',
// Only an enterprise server honours it; elsewhere it would just log a warning per
// poll. The license loads asynchronously, so a cold load may create the chat twice.
pollDelayMs: $enterpriseLicense ? 50 : undefined,
run: async ({ user_message, ...inputs }, { conversationId }) => {
const jobId = await onRunFlow(String(user_message), conversationId, inputs)
let jobId: string | undefined
try {
jobId = await onRunFlow(String(user_message), conversationId, inputs)
} catch (e) {
// The conversation is still answering a message sent elsewhere; the chat
// follows that turn instead of failing this one.
if (e instanceof ApiError && e.status === 409) {
throw turnRunningError(String(e.body)) ?? e
}
throw e
}
if (!jobId) throw new Error(`the ${subject} did not start`)
// The server creates the conversation with the run, so the sidebar can list
// it now, whatever becomes of the turn.
@@ -98,15 +134,46 @@
return jobId
},
onError: (error) => sendUserToast(`Failed to run ${subject}: ${error.message}`, true)
}
const api = new WindmillChatApi({ baseUrl, workspace: ws })
const createdList = createChat(options)
const createdPool = new FlowChatPool<FlowChatViewHost, ComposerAttachment>({
createChat: () => createChat(options),
createHost: (turns) => new FlowChatViewHost(turns),
disposeHost: (host) => host.dispose(),
holdsDraft: (host) => host.holdsDraft(),
// Every kind: a conversation the reader follows keeps running whatever the filter lists.
listRecent: async (page) =>
(await api.listConversations(flowPath, { page, perPage: 50, kind: 'all' })).map((row) => ({
id: row.id,
runningTurn: row.running_turn
? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq }
: undefined
}))
})
const unsubscribe = created.subscribe((s) => (chatState = s))
chat = created
const unsubscribeList = createdList.subscribe((s) => {
listState = s
// The kind filter narrows what the listing holds, while a conversation of the other
// kind keeps its panel mounted behind it. Remembered as each row is seen, so that
// panel's composer keeps saying why it cannot be written to.
for (const row of s.conversations) {
if (row.isTest !== undefined) conversationKinds.set(row.id, row.isTest)
}
})
const unsubscribePool = createdPool.subscribe((s) => (poolState = s))
listChat = createdList
pool = createdPool
return () => {
unsubscribe()
created.destroy()
unsubscribeList()
unsubscribePool()
createdPool.destroy()
createdList.destroy()
}
})
// The panel on screen. Every other panel the pool holds stays mounted behind it.
const shownKey = $derived(poolState?.shownKey)
// Derive additional inputs schema (excluding user_message) for chat mode
const additionalInputsSchema = $derived.by(() => {
const props = inputSchema?.properties ?? {}
@@ -123,12 +190,13 @@
</script>
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
{#if chat && chatState}
{#if listChat && listState && pool && poolState}
{#if !hideSidebar}
<FlowConversationsSidebar
bind:this={sidebar}
{chat}
{chatState}
{listChat}
{pool}
{poolState}
defaultKind={conversationKind}
canFilterKind={conversationKind !== 'deployed' && subject === 'flow'}
/>
@@ -136,24 +204,45 @@
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
the panel edge the way the session chat does, while the sidebar and the border
dividing it from the chat still reach the bottom. -->
<div class="flex flex-1 min-w-0 min-h-0 pb-3">
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
{#key chat}
<FlowChatInterface
{chat}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{identity}
{workspace}
{description}
{wideLayout}
{conversationKind}
{subject}
/>
{/key}
<div class="relative flex flex-1 min-w-0 min-h-0">
<!-- One panel per chat the pool holds, all mounted: the composer keeps what the
reader typed, and what is still being read finishes into it, so leaving a
conversation and coming back finds it as it was. The shown panel is in flow, so
the chat keeps a height of its own where the host gives none (the editor's
Test-flow panel); the others lie over it, invisible but laid out, which keeps
their transcript scrolled where it was. -->
{#each poolState.mounted as key (key)}
{@const panel = pool.get(key)}
{#if panel}
{@const shown = key === shownKey}
<div
class="flex min-w-0 min-h-0 pb-3 {shown ? 'relative flex-1' : 'absolute inset-0'}"
class:invisible={!shown}
class:pointer-events-none={!shown}
aria-hidden={!shown}
inert={!shown}
>
<FlowChatInterface
chat={panel.chat}
chatHost={panel.host}
bind:inputValues
isTestOf={(id) =>
listState?.conversations.find((c) => c.id === id)?.isTest ??
conversationKinds.get(id)}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{identity}
{workspace}
{description}
{wideLayout}
{conversationKind}
{subject}
/>
</div>
{/if}
{/each}
</div>
{/if}
</div>
@@ -3,14 +3,14 @@
import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte'
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
import type { FlowChatViewHost } from './flowChatViewHost.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { emptyString, type DynamicInput } from '$lib/utils'
import { onDestroy, tick, untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import { chatFlowKey } from './flowChatProps'
import { saveFlowChatInputs } from './flowChatProps'
import type { FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import {
@@ -30,6 +30,14 @@
interface Props {
chat: Chat
/** The conversation's host, which outlives this panel. One panel follows one chat for
* its whole life, so a later value of either prop never reaches it. */
chatHost: FlowChatViewHost
/** The flow inputs the reader chose, shared by every conversation in the flow: FlowChat
* holds them, since each conversation has a panel of its own mounted. */
inputValues: Record<string, any>
/** Whether a conversation is a test chat, once the list has said. */
isTestOf?: (conversationId: string) => boolean | undefined
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
@@ -50,6 +58,9 @@
let {
chat,
chatHost: chatHostProp,
inputValues = $bindable(),
isTestOf = undefined,
deploymentInProgress = false,
additionalInputsSchema,
flowModules,
@@ -97,12 +108,7 @@
const modelGap = $derived(agentModelGap(modelWiring, subject))
const showModelButton = $derived(showsModelButton(modelWiring))
// LocalStorage helpers
const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
let showInputsModal = $state(false)
// Conversation settings, persisted per flow: what the reader chose, and nothing else.
let inputValues = $state<Record<string, any>>(loadInputsFromStorage() ?? {})
let modalDraft = $state<Record<string, any>>({})
/** What the flow's own form would open on. */
@@ -128,31 +134,9 @@
// value, an author's default — is made safe before it reaches the provider.
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}`
}
function loadInputsFromStorage(): Record<string, any> | null {
try {
const stored = localStorage.getItem(getStorageKey())
return stored ? JSON.parse(stored) : null
} catch (e) {
console.error('Failed to load inputs from localStorage:', e)
return null
}
}
function saveInputsToStorage(values: Record<string, any>) {
try {
localStorage.setItem(getStorageKey(), JSON.stringify(values))
} catch (e) {
console.error('Failed to save inputs to localStorage:', e)
}
}
function setInputValue(name: string, value: any) {
inputValues = { ...inputValues, [name]: value }
saveInputsToStorage(inputValues)
saveFlowChatInputs({ path, identity }, inputValues)
}
function handleModalConfirm() {
@@ -167,47 +151,48 @@
)
)
inputValues = kept
saveInputsToStorage(inputValues)
saveFlowChatInputs({ path, identity }, inputValues)
showInputsModal = false
}
function openInputsModal() {
modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) }
modalDraft = { ...effectiveInputs, ...inputValues }
showInputsModal = true
}
// The host follows the chat it was built on for the life of this component: FlowChat
// remounts the interface under `{#key chat}`, so a later value of the prop never reaches it.
const chatHost = new FlowChatViewHost(
untrack(() => chat),
{
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
attachmentsTarget: () => attachmentsTarget,
attachmentsUnavailable: () =>
workspaceStorage.current
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
workspace: () => workspace,
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
// The model controls only: a retry changes model when the reader did, but replays
// the run's own attachments rather than whatever the composer holds now.
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
}
)
// The host belongs to the conversation, not to this panel: the pool keeps it alive so a
// message queued here still goes out once the reader has moved on. What it reads is this
// panel's, set on mount; one panel shows one conversation for its whole life.
const chatHost = untrack(() => chatHostProp)
chatHost.setOptions({
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
attachmentsTarget: () => attachmentsTarget,
attachmentsUnavailable: () =>
workspaceStorage.current
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
workspace: () => workspace,
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
// The model controls only: a retry changes model when the reader did, but replays
// the run's own attachments rather than whatever the composer holds now.
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
})
setChatViewHost(chatHost)
// Read off the host's own conversation: a message queued here goes out after the reader
// has moved on, when the shown conversation may be of the other kind.
const isTest = $derived.by(() => {
const id = chatHost.state.conversationId
return id === undefined ? undefined : isTestOf?.(id)
})
// A chat of the other kind can be read from here but not added to: the server refuses a
// preview run into a deployed conversation and the reverse, so the composer says why first.
const wrongKindReason = $derived.by(() => {
const { conversationId, conversations } = chatHost.state
const open = conversations.find((c) => c.id === conversationId)
if (open?.isTest === undefined || open.isTest === (conversationKind === 'test'))
return undefined
return open.isTest
if (isTest === undefined || isTest === (conversationKind === 'test')) return undefined
return isTest
? 'This chat was run from the flow editor. Start a new chat to continue here.'
: 'This chat belongs to the deployed flow. Start a new chat to test.'
})
onDestroy(() => chatHost.dispose())
// What the Configure-inputs modal asks for: every flow input the composer does not
// edit itself.
@@ -5,11 +5,13 @@
Plus,
Trash2,
Pen,
PencilLine,
Filter,
PanelLeftClose,
PanelLeftOpen
} from 'lucide-svelte'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -21,11 +23,16 @@
import { twMerge } from 'tailwind-merge'
import { fade } from 'svelte/transition'
import { tick, untrack } from 'svelte'
import type { Chat, ChatState, Conversation, ConversationKind } from 'windmill-chat'
import type { Chat, Conversation, ConversationKind } from 'windmill-chat'
import type { FlowChatPool, FlowChatPoolState } from './flowChatPool'
import type { ComposerAttachment, FlowChatViewHost } from './flowChatViewHost.svelte'
interface Props {
chat: Chat
chatState: ChatState
/** Lists, renames and deletes the flow's conversations; runs no turn itself. */
listChat: Chat
/** The conversations' own chats: which one is shown, and what each is doing. */
pool: FlowChatPool<FlowChatViewHost, ComposerAttachment>
poolState: FlowChatPoolState
/**
* Which conversations the list holds at first. The editor shows its own test chats,
* since testing is what happens there; a deployed flow shows the chats its users
@@ -40,7 +47,13 @@
canFilterKind?: boolean
}
let { chat, chatState, defaultKind = 'deployed', canFilterKind = false }: Props = $props()
let {
listChat,
pool,
poolState,
defaultKind = 'deployed',
canFilterKind = false
}: Props = $props()
let expanded = $state(false)
let list = $state<InfiniteList | undefined>(undefined)
@@ -58,13 +71,13 @@
let renameDraft = $state('')
let renameInput = $state<TextInput | undefined>(undefined)
const turnInFlight = $derived(
chatState.status === 'submitted' || chatState.status === 'streaming'
const totalUnread = $derived(
Object.values(poolState.unread).reduce((total, count) => total + count, 0)
)
$effect(() => {
const l = list
const c = chat
const c = listChat
if (!l) return
untrack(() => {
// Every load goes through here, the first one and infinite scroll included. A
@@ -72,13 +85,18 @@
// selected kind brings its own, whichever of the two lands last.
l.setLoader(async (page, perPage) => {
const requested = kind
// Only a listing read now says which turns run: the rows the chat holds keep
// what the last one said, and a rename or delete publishes those again.
const since = pool.listingStarted()
const rows = await c.loadConversations({ page, perPage, kind: requested })
pool.setListed(rows, since)
return requested === kind ? rows : items
})
l.setDeleteItemFn(async (id: string) => {
deletingId = id
try {
await c.deleteConversation(id)
pool.forget(id)
sendUserToast('Conversation deleted successfully')
} catch (error) {
console.error('Failed to delete conversation:', error)
@@ -103,10 +121,10 @@
await list?.loadData('forceRefresh')
}
const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId))
const draftShown = $derived(draft && poolState.selectedId === undefined)
function newChat() {
chat.newConversation()
pool.newChat()
draft = true
}
@@ -118,17 +136,16 @@
/**
* Narrow the list to one kind of chat and reload it. The open conversation goes with it
* when it is not of the new kind: the composer sends into whatever is selected, and a
* when it is not of the new kind: the composer sends into whatever is shown, and a
* conversation keeps the kind it was created with, so a turn sent into one the list no
* longer shows would be stored where nothing here lists it.
* longer shows would be stored where nothing here lists it. A turn running in it goes on.
*/
async function setKind(next: ConversationKind) {
// A turn writes into the open conversation, which a kind that excludes it would close.
if (next === kind || turnInFlight) return
if (next === kind) return
kind = next
const open = items.find((c) => c.id === chatState.conversationId)
const open = items.find((c) => c.id === poolState.selectedId)
const stillListed = open === undefined || next === 'all' || (next === 'test') === open.isTest
if (!stillListed) chat.newConversation()
if (!stillListed) pool.newChat()
await list?.loadData('forceRefresh')
}
@@ -149,11 +166,11 @@
const current = items.find((c) => c.id === id)
if (!current || title === '' || title === current.title) return
try {
await chat.renameConversation(id, title)
await listChat.renameConversation(id, title)
// The list holds its own rows, loaded through the loader: patched rather than
// reloaded, so the row keeps its place without a round trip. The title is read
// back from the chat, which holds it as the server stored it (a long one is cut).
const stored = chat.getState().conversations.find((c) => c.id === id)?.title ?? title
const stored = listChat.getState().conversations.find((c) => c.id === id)?.title ?? title
items = items.map((c) => (c.id === id ? { ...c, title: stored } : c))
} catch (error) {
console.error('Failed to rename conversation:', error)
@@ -177,8 +194,34 @@
function getConversationTitle(conversation: Conversation): string {
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
}
/** The session sidebar's dot vocabulary, which knows no `queued`: that is its own mark. */
function dotStatus(conversationId: string): 'streaming' | 'error' | 'idle' {
const activity = poolState.activity[conversationId]
return activity === 'running' ? 'streaming' : activity === 'error' ? 'error' : 'idle'
}
</script>
{#snippet statusDot(conversation: Conversation)}
<!-- The AI session sidebar's dot, with the resting mark this list needs: a session rests
as a workspace or a fork, a conversation as a test run or one of the deployed flow's. -->
<SessionStatusDot
status={dotStatus(conversation.id)}
isFork={false}
restingTitle={conversation.isTest
? 'Test chat, run from the flow editor'
: 'Chat on the deployed flow'}
>
{#snippet resting()}
<span
class="w-[6px] h-[6px] rounded-full {conversation.isTest
? 'border border-gray-400 dark:border-gray-500'
: 'bg-gray-300 dark:bg-gray-600'}"
></span>
{/snippet}
</SessionStatusDot>
{/snippet}
<div
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {expanded
? 'w-60'
@@ -219,12 +262,7 @@
{#if canFilterKind}
<!-- No focus trap: opening a row's menu does not close this popover, and a
trapped popover pulls focus back from the rename field that menu opens. -->
<Popover
placement="bottom-start"
closeButton={false}
disableFocusTrap
disabled={turnInFlight}
>
<Popover placement="bottom-start" closeButton={false} disableFocusTrap>
{#snippet trigger()}
<!-- Icon-only next to the wider New chat: which kind is listed is named in
the title and by the group inside. -->
@@ -233,10 +271,7 @@
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Filter }}
disabled={turnInFlight}
title={turnInFlight
? 'Wait for the current answer to change which chats are listed'
: `Filter conversations · ${KIND_LABELS[kind]}`}
title={`Filter conversations · ${KIND_LABELS[kind]}`}
iconOnly
/>
{/snippet}
@@ -245,7 +280,6 @@
<ToggleButtonGroup
selected={kind}
onSelected={(next) => setKind(next as ConversationKind)}
disabled={turnInFlight}
noWFull
>
{#snippet children({ item })}
@@ -274,11 +308,19 @@
unifiedSize="md"
startIcon={{ icon: MessageCircle }}
onClick={() => (expanded = true)}
title="{items.length} conversation{items.length !== 1 ? 's' : ''}"
title="{items.length} conversation{items.length !== 1 ? 's' : ''}{totalUnread > 0
? `, ${totalUnread} unread`
: ''}"
variant="subtle"
btnClasses="w-fit px-2 relative"
>
<CountBadge count={items.length} small alwaysVisible={true} class="right-[3px] top-[3px]" />
<!-- The same badge the rows carry, over the one icon that stands for all of them:
collapsed, what is worth a number is what arrived, not how many chats exist. -->
<UnreadCountBadge
count={totalUnread}
small
class="absolute right-[3px] top-[3px] pointer-events-none"
/>
</Button>
</div>
{/if}
@@ -299,7 +341,6 @@
onClick={(e) => {
e?.stopPropagation()
draft = false
chat.newConversation()
}}
title="Discard draft"
destructive
@@ -314,7 +355,7 @@
<InfiniteList
bind:this={list}
bind:items
selectedItemId={chatState.conversationId}
selectedItemId={poolState.selectedId}
noBorder={true}
rounded={false}
preventXOverflow={true}
@@ -348,19 +389,41 @@
/>
</div>
{:else}
{@const unread = poolState.unread[conversation.id] ?? 0}
{@const queued = !!poolState.queued[conversation.id]}
<Button
unifiedSize="md"
variant="subtle"
onClick={() => {
draft = false
chat.selectConversation(conversation.id)
pool.select(conversation.id)
}}
selected={chatState.conversationId === conversation.id}
btnClasses="transition-all duration-150 group"
selected={poolState.selectedId === conversation.id}
btnClasses="transition-all duration-150 group gap-2"
>
<span class="flex-1 text-left truncate">
<!-- In the slot New chat's icon occupies above, so the column lines up. Says
what the chat is doing where there is something to say, and which kind of
chat it is otherwise. -->
{@render statusDot(conversation)}
<span
class={twMerge(
'flex-1 text-left truncate',
unread > 0 ? 'font-semibold text-primary' : ''
)}
>
{getConversationTitle(conversation)}
</span>
{#if queued || unread > 0}
<span class="shrink-0 inline-flex items-center gap-1">
{#if queued}
<PencilLine
class="w-3 h-3 text-tertiary"
aria-label="Message waiting to send"
/>
{/if}
<UnreadCountBadge count={unread} />
</span>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -0,0 +1,290 @@
import {
TurnRunningError,
type Chat,
type ChatMessage,
type ChatState,
type RunningTurn,
type SendMessageOptions
} from 'windmill-chat'
/**
* Whether the turn the user message at `index` started failed: its last row before the
* next user message reports `success: false`. The last row, not any row: a tool call can
* fail and the agent still answer, and that turn completed.
*/
export function turnFailed(messages: readonly ChatMessage[], index: number): boolean {
let last: ChatMessage | undefined
for (let i = index + 1; i < messages.length; i++) {
const message = messages[i]
if (message.role === 'user') break
last = message
}
return last?.success === false
}
/** Whether the latest turn failed, per `turnFailed`. False before any turn. */
export function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') return turnFailed(messages, i)
}
return false
}
export function isBusy(status: ChatState['status']): boolean {
return status === 'submitted' || status === 'streaming'
}
/** A message written and not yet sent. What it carries besides its text is opaque here. */
export interface Draft<A> {
text: string
attachments: readonly A[]
}
export function emptyDraft<A>(): Draft<A> {
return { text: '', attachments: [] }
}
export function isEmptyDraft<A>(draft: Draft<A>): boolean {
return draft.text === '' && draft.attachments.length === 0
}
/** `later` after `earlier`, one line each: typing again adds to what waits. */
function mergeDrafts<A>(earlier: Draft<A>, later: Draft<A>): Draft<A> {
return {
text: [earlier.text, later.text].filter(Boolean).join('\n'),
attachments: [...earlier.attachments, ...later.attachments]
}
}
/** What turns a draft into a send, and hears of a send the chat refused. */
export interface DraftSender<A> {
/**
* The chat's send for `draft`, read as it goes out, so a queued message takes the inputs
* of that moment. `replayInputs` are a failed turn's own run arguments, for a retry.
* Undefined refuses it, and the draft is handed back.
*/
prepareSend(
draft: Draft<A>,
replayInputs?: Record<string, unknown>
): SendMessageOptions | undefined
/** The chat refused a send outright (an upload that failed, Stop during it); the draft
* is handed back after this. */
sendFailed(error: unknown, draft: Draft<A>): void
}
/**
* The turns of one conversation's chat, and what the reader wrote around them: the queued
* message waiting for the running turn, and text a refused send handed back for a composer
* to take. Both exist nowhere else, so a chat holding either is never released.
*/
export class ConversationTurns<A> {
readonly chat: Chat
readonly #sender: () => DraftSender<A>
#queued: Draft<A> = emptyDraft()
/** A retry's own run arguments, kept only while that retry is all there is to send: merged
* with anything else it is no longer that turn's replay, and goes out on current inputs. */
#queuedReplay: Record<string, unknown> | undefined
#returned: Draft<A> = emptyDraft()
/** Settles when the chat has released the last turn sent or followed here. */
#turnDone: Promise<unknown> = Promise.resolve()
/** Sends whose outcome has not reached this yet: a refusal hands their draft back. */
#sending = 0
/** Stops so far, so a send can tell it was stopped before the chat refused it. */
#stops = 0
#status: ChatState['status']
readonly #listeners = new Set<() => void>()
readonly #unsubscribe: () => void
#disposed = false
constructor(chat: Chat, sender: () => DraftSender<A>) {
this.chat = chat
this.#sender = sender
this.#status = chat.getState().status
this.#unsubscribe = chat.subscribe((state) => this.#onState(state))
}
/** The queued message; empty when nothing waits. */
get queued(): Draft<A> {
return this.#queued
}
/** Text written here and not sent: queued, or handed back and not yet taken. */
get holdsText(): boolean {
return !isEmptyDraft(this.#queued) || !isEmptyDraft(this.#returned)
}
/** Nothing here exists anywhere else: no text held, and no send that could still hand
* its draft back. The chat publishes `idle` before it rejects a send it withdrew. */
get releasable(): boolean {
return !this.holdsText && this.#sending === 0
}
/** Calls `listener` whenever the queued or handed-back text changes. */
subscribe(listener: () => void): () => void {
this.#listeners.add(listener)
return () => {
this.#listeners.delete(listener)
}
}
/**
* Sends `draft`, or queues it while a turn runs. False when nothing was sent: no text,
* or the sender refused it and it was handed back. A conversation still answering a
* message sent elsewhere has that turn followed here, with this one queued behind it.
*/
send = async (draft: Draft<A>, replayInputs?: Record<string, unknown>): Promise<boolean> => {
const text = draft.text.trim()
// An AI agent step refuses a run with no `user_message`.
if (!text) return false
const message = { text, attachments: draft.attachments }
if (isBusy(this.chat.getState().status)) {
this.queue(message, replayInputs)
return true
}
const options = this.#sender().prepareSend(message, replayInputs)
if (!options) {
this.#handBack(message)
return false
}
const stops = this.#stops
this.#sending++
// A run that fails is reported through the chat's `onError` and as a failed message;
// the promise only rejects when the chat refuses the turn outright.
const turn = this.chat
.sendMessage(text, options)
.catch((e) => {
if (this.#disposed) return
// Stop pressed while the run was asked for: the message goes back, whatever the
// refusal says, rather than out after the turn it names.
const stopped = this.#stops !== stops
// A chat that fell back to local history cannot follow the running turn, and a
// queue that did not wait for it would be refused again as soon as it went out.
const followable = this.chat.getState().history === 'server'
if (e instanceof TurnRunningError && !stopped && followable) {
// Ahead of what was typed while it waited for the refusal: it was written first.
const alone = isEmptyDraft(this.#queued)
this.#queued = mergeDrafts(message, this.#queued)
this.#queuedReplay = alone ? replayInputs : undefined
this.#notify()
this.resume(e.turn)
return
}
if (!stopped) this.#sender().sendFailed(e, message)
// What was queued behind it comes back first: the chat publishes `idle` when it
// withdraws the turn, and a queue left in place would go out as if the turn had run.
this.dequeue()
this.#handBack(message)
})
.finally(() => {
this.#sending--
})
this.#turnDone = turn
await turn
return true
}
/** Follows a turn this chat did not start, so what is queued waits for it. */
resume(turn: RunningTurn): void {
this.#turnDone = this.chat.resumeTurn(turn)
}
queue(draft: Draft<A>, replayInputs?: Record<string, unknown>): void {
const text = draft.text.trim()
if (!text && draft.attachments.length === 0) return
const alone = isEmptyDraft(this.#queued)
this.#queued = mergeDrafts(this.#queued, { text, attachments: draft.attachments })
this.#queuedReplay = alone ? replayInputs : undefined
this.#notify()
}
/** Hands the queued message back for the composer. */
dequeue(): void {
const queued = this.#takeQueued()
if (!isEmptyDraft(queued)) this.#handBack(queued)
}
/** Stop means stop: what was queued goes back rather than out after some later turn. */
stop(): void {
this.#stops++
this.dequeue()
void this.chat.stop()
}
/** The handed-back text, for the composer taking it. */
takeReturned(): Draft<A> {
const returned = this.#returned
if (isEmptyDraft(returned)) return returned
this.#returned = emptyDraft()
this.#notify()
return returned
}
/** Everything written here and not sent, for the chat taking this one's place. */
takeHeld(): Draft<A> {
const held = mergeDrafts(this.#returned, this.#queued)
this.#returned = emptyDraft()
this.#queued = emptyDraft()
this.#queuedReplay = undefined
this.#notify()
return held
}
adopt(draft: Draft<A>): void {
if (!isEmptyDraft(draft)) this.#handBack(draft)
}
/** Drops what is queued: a flush still waiting on the turn would otherwise start a run
* for a chat that is gone. */
dispose(): void {
this.#disposed = true
this.#queued = emptyDraft()
this.#queuedReplay = undefined
this.#unsubscribe()
this.#listeners.clear()
}
#onState(state: ChatState): void {
const was = this.#status
this.#status = state.status
if (!isBusy(was) || isBusy(state.status)) return
// The turn settled. The queued message goes out once the turn is released, not now:
// the chat publishes `idle` from inside its own `sendMessage`, which still counts the
// turn as open until it returns, and a send made before that would be refused as a
// second turn. After a failure it goes back instead, where the reader would rather look
// at the error than pile on. A failed flow settles as `idle` too, with its error as the
// answer, so the messages decide.
if (state.status === 'idle' && !lastTurnFailed(state.messages)) {
void this.#turnDone.then(() => this.#flush())
} else {
this.dequeue()
}
}
#flush(): void {
// Same rule as `send`, read before the queue is drained: a turn with no message cannot
// run, and taking the queue for it would drop the attachments on the floor.
if (!this.#queued.text || this.#disposed) return
const replayInputs = this.#queuedReplay
void this.send(this.#takeQueued(), replayInputs)
}
#takeQueued(): Draft<A> {
const queued = this.#queued
if (isEmptyDraft(queued)) return queued
this.#queued = emptyDraft()
this.#queuedReplay = undefined
this.#notify()
return queued
}
#handBack(draft: Draft<A>): void {
// In front, as a composer puts it back: a refused message is handed back after what was
// queued behind it, and it was written first.
this.#returned = mergeDrafts(draft, this.#returned)
this.#notify()
}
#notify(): void {
for (const listener of this.#listeners) listener()
}
}
@@ -0,0 +1,430 @@
import { describe, expect, it, vi } from 'vitest'
import type { Chat, ChatMessage, ChatState, Conversation } from 'windmill-chat'
import { FlowChatPool, type ListedConversation } from './flowChatPool'
import type { ConversationTurns, DraftSender } from './conversationTurns'
/** A chat whose state the test drives; `selectConversation` only sets the id. */
function fakeChat() {
let state: ChatState = {
conversationId: undefined,
messages: [],
status: 'idle',
error: undefined,
conversations: [],
history: 'server',
loadingMessages: false,
hasMoreMessages: false
}
const listeners = new Set<(s: ChatState) => void>()
const set = (patch: Partial<ChatState>) => {
state = { ...state, ...patch }
for (const listener of listeners) listener(state)
}
const chat = {
getState: () => state,
subscribe: (listener: (s: ChatState) => void) => {
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
},
sendMessage: vi.fn(async () => {}),
resumeTurn: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
newConversation: vi.fn(),
// Like the SDK: the id and the loading flag are set before the first page is read.
selectConversation: vi.fn(async (id: string) =>
set({ conversationId: id, loadingMessages: true })
),
loadConversations: vi.fn(async () => []),
deleteConversation: vi.fn(async () => {}),
renameConversation: vi.fn(async () => {}),
loadOlderMessages: vi.fn(async () => {}),
refreshMessages: vi.fn(async () => {}),
destroy: vi.fn()
} satisfies Chat
return { chat, set }
}
function answer(id: string, pending = false): ChatMessage {
return { id, role: 'assistant', content: 'done', success: true, createdAt: '', pending }
}
function conversation(id: string, extra: Partial<Conversation> = {}): Conversation {
return { id, title: id, createdAt: '', updatedAt: '', ...extra }
}
type TestHost = DraftSender<never> & { chat: Chat; turns: ConversationTurns<never> }
function pool(
options: {
keepSettled?: number
listRecent?: (page: number) => Promise<readonly ListedConversation[]>
holdsDraft?: (host: TestHost) => boolean
} = {}
) {
const chats: ReturnType<typeof fakeChat>[] = []
const hosts: TestHost[] = []
const dispose = vi.fn()
const created = new FlowChatPool<TestHost, never>({
createChat: () => {
const fake = fakeChat()
chats.push(fake)
return fake.chat
},
createHost: (turns) => {
const host: TestHost = {
chat: turns.chat,
turns,
prepareSend: () => ({}),
sendFailed: () => {}
}
hosts.push(host)
return host
},
disposeHost: dispose,
holdsDraft: options.holdsDraft ?? (() => false),
listRecent: options.listRecent ?? (async () => []),
keepSettled: options.keepSettled,
pollMs: 10
})
const chatOf = (id: string) => chats.find((c) => c.chat.getState().conversationId === id)!
const fakeOf = (chat: Chat) => chats.find((c) => c.chat === chat)!
const turnsOf = (id: string) =>
hosts.find((host) => host.chat.getState().conversationId === id)!.turns
/** The panel on screen, as the state names it. */
const shown = () => created.get(created.getState().shownKey)!
/** The panel showing a conversation, while the pool still holds one for it. */
const held = (id: string) =>
created
.getState()
.mounted.map((key) => created.get(key)!)
.find((panel) => panel.chat.getState().conversationId === id)
return { pool: created, chatOf, fakeOf, turnsOf, held, shown, dispose }
}
const typed = (text: string) => ({ text, attachments: [] })
describe('FlowChatPool', () => {
it('runs a turn in each of two conversations and counts an answer that lands out of view', () => {
const { pool: p, chatOf } = pool()
p.select('a')
chatOf('a').set({ loadingMessages: false, status: 'streaming' })
p.select('b')
chatOf('b').set({ status: 'submitted' })
expect(p.getState().activity).toEqual({ a: 'running', b: 'running' })
chatOf('a').set({ status: 'streaming', messages: [answer('m1', true)] })
chatOf('a').set({ status: 'idle', messages: [answer('m1')] })
expect(p.getState().activity).toEqual({ b: 'running' })
expect(p.getState().unread).toEqual({ a: 1 })
p.select('a')
expect(p.getState().unread).toEqual({})
expect(chatOf('a').chat.destroy).not.toHaveBeenCalled()
p.destroy()
})
it('watches a turn another page started through the listing, and follows it when selected', async () => {
let listed: ListedConversation[] = []
let reads = 0
const { pool: p, chatOf } = pool({
listRecent: async () => {
reads++
return listed
}
})
const turn = { jobId: 'job-1', userSeq: 7 }
p.setListed(
[conversation('a', { runningTurn: turn }), conversation('b', { runningTurn: turn })],
p.listingStarted()
)
expect(p.getState().activity).toEqual({ a: 'running', b: 'running' })
p.select('a')
expect(chatOf('a').chat.resumeTurn).toHaveBeenCalledWith(turn)
// Turn 1 ended and another one started in b: the row keeps running on the newer turn.
listed = [conversation('b', { runningTurn: { jobId: 'job-2', userSeq: 9 } })]
await vi.waitFor(() => expect(reads).toBeGreaterThan(0))
expect(p.getState().activity).toEqual({ b: 'running' })
listed = [conversation('b')]
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
p.destroy()
})
it('reads past the first page for a running row that has written nothing for a while', async () => {
let running = true
const quiet = () =>
conversation('z', running ? { runningTurn: { jobId: 'job-z', userSeq: 3 } } : {})
const pagesRead: number[] = []
const { pool: p } = pool({
// Others were active since: the running row is on page 2.
listRecent: async (page) => {
pagesRead.push(page)
return page === 1 ? [conversation('x'), conversation('y')] : page === 2 ? [quiet()] : []
}
})
p.setListed([quiet()], p.listingStarted())
// The poll must have read page 2 to still call it running: page 1 does not hold it.
await vi.waitFor(() => expect(pagesRead).toContain(2))
expect(p.getState().activity).toEqual({ z: 'running' })
running = false
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
p.destroy()
})
it('brings running rows back once the listing answers again, wherever they sit', async () => {
let answers = false
const turn = { jobId: 'job-1', userSeq: 7 }
const { pool: p } = pool({
listRecent: async (page) => {
if (!answers) throw new Error('offline')
// Watching nothing, the recovery read must go past the first page to find it.
return page === 1
? [conversation('x')]
: page === 2
? [conversation('a', { runningTurn: turn })]
: []
}
})
p.setListed([conversation('a', { runningTurn: turn })], p.listingStarted())
expect(p.getState().activity).toEqual({ a: 'running' })
// Three failed listings in a row: the row goes quiet rather than staying stuck.
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
answers = true
await vi.waitFor(() => expect(p.getState().activity).toEqual({ a: 'running' }), {
timeout: 3000
})
p.destroy()
})
it('keeps what a later listing said over an earlier one that lands after it', () => {
const { pool: p } = pool()
const earlier = p.listingStarted()
const later = p.listingStarted()
p.setListed([conversation('a', { runningTurn: { jobId: 'job-2', userSeq: 9 } })], later)
p.setListed([conversation('a')], earlier)
expect(p.getState().activity).toEqual({ a: 'running' })
p.destroy()
})
it('does not take a turn back from a listing requested before it ended', () => {
const { pool: p, chatOf } = pool()
p.select('a')
chatOf('a').set({ status: 'streaming' })
const since = p.listingStarted()
chatOf('a').set({ status: 'idle', messages: [answer('m1')] })
p.setListed([conversation('a', { runningTurn: { jobId: 'job-1', userSeq: 3 } })], since)
expect(p.getState().activity).toEqual({})
expect(chatOf('a').chat.resumeTurn).not.toHaveBeenCalled()
p.destroy()
})
it('counts no answer of a first page that lands after the reader moved on, and rereads a held chat on return', () => {
const { pool: p, chatOf } = pool()
p.select('a')
p.select('b')
chatOf('a').set({ loadingMessages: false, messages: [answer('old-1'), answer('old-2')] })
expect(p.getState().unread).toEqual({})
p.select('a')
expect(chatOf('a').chat.refreshMessages).toHaveBeenCalledTimes(1)
p.destroy()
})
it('takes a chat back as the new one when its first message never ran', () => {
const { pool: p, fakeOf, held, shown } = pool()
const draft = shown()
const { set } = fakeOf(draft.chat)
// A new chat's first message names its conversation, then is withdrawn: the upload
// failed, or Stop was pressed while it ran, so that conversation was never created.
set({ conversationId: 'new-1' })
expect(p.getState().selectedId).toBe('new-1')
set({ conversationId: undefined })
expect(p.getState().selectedId).toBeUndefined()
expect(held('new-1')).toBeUndefined()
expect(p.getState().activity).toEqual({})
// The next message mints its own id on that same chat, and the pool follows it there.
expect(shown()).toBe(draft)
set({ conversationId: 'new-2' })
expect(p.getState().selectedId).toBe('new-2')
expect(held('new-2')).toBe(draft)
p.destroy()
})
it('hands what a withdrawn chat held to the new chat that took its place', async () => {
const { pool: p, fakeOf, shown } = pool()
const withdrawn = shown()
const { set } = fakeOf(withdrawn.chat)
set({ conversationId: 'new-1' })
;(withdrawn.host as TestHost).turns.adopt(typed('typed while uploading'))
// The reader opens another new chat while the first message is still uploading.
const kept = p.newChat()
expect(kept).not.toBe(withdrawn)
set({ conversationId: undefined })
// Not released yet: the send reports what it could not do after this.
expect(withdrawn.chat.destroy).not.toHaveBeenCalled()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(withdrawn.chat.destroy).toHaveBeenCalled()
expect((kept.host as TestHost).turns.takeReturned().text).toBe('typed while uploading')
expect(shown()).toBe(kept)
p.destroy()
})
it('keeps the chat the reader is on when two first turns withdraw with both composers written in', async () => {
const written = new Set<unknown>()
const { pool: p, fakeOf, shown } = pool({ holdsDraft: (host) => written.has(host) })
const first = shown()
const firstChat = fakeOf(first.chat)
firstChat.set({ conversationId: 'new-1' })
written.add(first.host)
// A second first message goes out from a new chat, and is the one on screen.
const second = p.newChat()
const secondChat = fakeOf(second.chat)
secondChat.set({ conversationId: 'new-2' })
written.add(second.host)
expect(shown()).toBe(second)
// Both uploads fail, the hidden one first: the reader must keep the panel in front of
// them, since its composer is the only place what they wrote in it exists.
firstChat.set({ conversationId: undefined })
expect(shown()).toBe(second)
secondChat.set({ conversationId: undefined })
expect(shown()).toBe(second)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(second.chat.destroy).not.toHaveBeenCalled()
p.destroy()
})
it('keeps a withdrawn chat whose composer holds a draft, over the empty one that replaced it', async () => {
let written: unknown
const { pool: p, fakeOf, shown } = pool({ holdsDraft: (host) => host === written })
const withdrawn = shown()
const { set } = fakeOf(withdrawn.chat)
set({ conversationId: 'new-1' })
// The reader writes a follow-up in its composer, then opens another new chat.
written = withdrawn.host
const opened = p.newChat()
set({ conversationId: undefined })
// Nothing but that composer holds what was written, so its panel is the one that stays.
expect(shown()).toBe(withdrawn)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(withdrawn.chat.destroy).not.toHaveBeenCalled()
expect(opened.chat.destroy).toHaveBeenCalled()
p.destroy()
})
it('keeps a settled chat that still holds a queued message, and marks its row', () => {
const { pool: p, chatOf, turnsOf, held } = pool({ keepSettled: 1 })
p.select('typed')
turnsOf('typed').queue(typed('later'))
for (const id of ['b', 'c', 'd']) p.select(id)
expect(held('typed')).toBeDefined()
expect(p.getState().queued).toEqual({ typed: true })
expect(chatOf('typed').chat.destroy).not.toHaveBeenCalled()
// Once it is taken back, the chat is releasable like any other.
turnsOf('typed').takeHeld()
p.select('e')
expect(held('typed')).toBeUndefined()
p.destroy()
})
it('keeps a chat whose send has not settled, so a refusal can hand its draft back', async () => {
const { pool: p, chatOf, turnsOf, held } = pool({ keepSettled: 1 })
p.select('a')
let refuse = (_e: Error) => {}
chatOf('a').chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
const sent = turnsOf('a').send(typed('with a file'))
for (const id of ['b', 'c', 'd']) p.select(id)
expect(held('a')).toBeDefined()
refuse(new Error('upload failed (500)'))
await sent
expect(turnsOf('a').takeReturned().text).toBe('with a file')
p.destroy()
})
it('keeps a chat whose composer holds a draft, whatever its turns hold', () => {
let typedIn = ''
const {
pool: p,
chatOf,
held
} = pool({
keepSettled: 1,
holdsDraft: (host) => host.chat.getState().conversationId === typedIn
})
p.select('typed')
typedIn = 'typed'
for (const id of ['b', 'c', 'd']) p.select(id)
expect(held('typed')).toBeDefined()
expect(chatOf('typed').chat.destroy).not.toHaveBeenCalled()
// Sent or taken back, the chat is releasable like any other.
typedIn = ''
p.select('e')
expect(held('typed')).toBeUndefined()
p.destroy()
})
it('names its panels apart from another pool\'s, so a rebuilt chat cannot reuse one', () => {
// The reader opens another flow: that pool is destroyed and a new one takes its place,
// in the same `{#each}`. A key reused there would keep the old flow's panel alive —
// composer, host and all — for a chat that no longer exists.
const first = pool()
const second = pool()
const keys = [...first.pool.getState().mounted, ...second.pool.getState().mounted]
expect(new Set(keys).size).toBe(keys.length)
first.pool.destroy()
second.pool.destroy()
})
it('keeps one panel key per chat, across the first turn naming its conversation', () => {
const { pool: p, fakeOf, held, shown } = pool()
const startedOn = p.getState().shownKey
expect(p.getState().mounted).toEqual([startedOn])
const started = shown()
// The panel is where the reader is typing: its key must not change under it when the
// send names the conversation, or Svelte tears the composer down mid-turn.
fakeOf(started.chat).set({ conversationId: 'a', status: 'submitted' })
expect(p.getState()).toMatchObject({ mounted: [startedOn], shownKey: startedOn })
expect(p.getState().selectedId).toBe('a')
expect(held('a')).toBe(started)
// Nor when the send is refused and the chat goes back to being the new chat.
fakeOf(started.chat).set({ conversationId: undefined, status: 'idle' })
expect(p.getState()).toMatchObject({ mounted: [startedOn], shownKey: startedOn })
expect(p.getState().selectedId).toBeUndefined()
// A conversation opened beside it gets a key of its own, and both panels stay.
p.select('b')
const opened = p.getState().shownKey
expect(opened).not.toBe(startedOn)
expect(p.getState().mounted).toEqual([startedOn, opened])
expect(p.get(opened)).toBe(shown())
// Moving between them must not reorder the keys: a key that moves takes its panel out
// of the DOM and back in, which loses the transcript's scroll.
p.newChat()
p.select('b')
expect(p.getState().mounted).toEqual([startedOn, opened])
p.destroy()
})
it('releases settled chats past the budget, never one still running', () => {
const { pool: p, chatOf, held, dispose } = pool({ keepSettled: 1 })
p.select('busy')
chatOf('busy').set({ status: 'streaming' })
p.select('old')
p.select('recent')
p.select('shown')
expect(chatOf('old').chat.destroy).toHaveBeenCalled()
expect(held('old')).toBeUndefined()
expect(held('recent')).toBeDefined()
expect(held('busy')).toBeDefined()
expect(dispose).toHaveBeenCalledTimes(1)
p.destroy()
})
})
@@ -0,0 +1,468 @@
import type { Chat, ChatState, Conversation } from 'windmill-chat'
import { ConversationTurns, isBusy, lastTurnFailed, type DraftSender } from './conversationTurns'
/** Listing reads in a row that may fail before the rows stop saying their turns run. */
const POLL_GIVE_UP = 3
/** Pages of the listing one poll reads at most, looking for the rows it watches. */
const POLL_PAGES = 5
/** How much slower the listing is read once it has stopped answering. */
const RECOVERY_SLOWDOWN = 10
/**
* Names panels across every pool on the page, not just within one: a pool is rebuilt when the
* reader opens another flow, and a key reused there would have Svelte keep the panel of the
* flow just left — composer, host and all — for a chat that is gone.
*/
let panelKeys = 0
/** What a conversation's row says about it. */
export type ConversationActivity = 'running' | 'error' | 'idle'
/** The part of a listed conversation that says whether a turn runs in it. */
export type ListedConversation = Pick<Conversation, 'id' | 'runningTurn'>
export interface FlowChatPoolState {
/** The conversation shown. Unset for a new chat that has not run its first turn. */
selectedId: string | undefined
/** Only conversations with something to say; absent means idle. */
activity: Record<string, ConversationActivity>
/** Answers that arrived in a conversation while another one was shown. */
unread: Record<string, number>
/** Conversations holding a queued message. */
queued: Record<string, true>
/**
* A key per chat with a panel — the conversations followed, and the chat a new one starts
* on — oldest chat first, an order that never changes while a chat is held. Each keeps its
* panel mounted: a composer holds what the reader typed, and unmounting it would take that
* with it. The key belongs to the chat, not to its conversation, so a new chat's panel
* survives its first turn naming the conversation.
*/
mounted: string[]
/** The key in `mounted` whose panel is on screen. */
shownKey: string
}
export interface PooledChat<H> {
chat: Chat
host: H
}
export interface FlowChatPoolOptions<H extends DraftSender<A>, A> {
/** A chat on the flow with no conversation selected. */
createChat(): Chat
/** The view of one conversation; its sends go through `turns`. */
createHost(turns: ConversationTurns<A>): H
disposeHost(host: H): void
/** Whether the composer showing this conversation holds text or files not sent yet. Such
* a chat is never released: its panel is the only place that draft exists. */
holdsDraft(host: H): boolean
/**
* A page of the flow's conversations of every kind, most recently active first, as the
* server lists them now; empty past the last one. Read while a conversation this pool
* follows no chat for is running, so its row stops saying so once its turn ends.
*/
listRecent(page: number): Promise<readonly ListedConversation[]>
/** Settled conversations kept in memory beside the shown and the busy ones. */
keepSettled?: number
pollMs?: number
}
interface Entry<H, A> extends PooledChat<H> {
/** Names this chat's panel for its whole life, whatever conversation the chat ends up on. */
key: string
turns: ConversationTurns<A>
unsubscribe: () => void
lastShownAt: number
/** Assistant messages already counted, so a message counts once as it settles. */
counted: Set<string>
/** Its first page has been read: answers already there were never unread. */
loaded: boolean
busy: boolean
/** The pool's clock when its last turn ended. */
settledAt: number
}
/**
* The conversations of one flow chat, each on its own `Chat` so that each can run a turn
* while another is shown. A `Chat` follows one conversation for its whole life and is
* never switched, which is what lets its turn keep running in the background.
*
* Plain TypeScript on the `windmill-chat` API, with the view host left generic: nothing
* here is specific to Svelte or to the app, so it can move into the SDK as is.
*/
export class FlowChatPool<H extends DraftSender<A>, A> {
readonly #options: FlowChatPoolOptions<H, A>
readonly #entries = new Map<string, Entry<H, A>>()
/** Every chat held, by panel key, so a panel can be found without knowing its conversation. */
readonly #byKey = new Map<string, Entry<H, A>>()
/** The chat a new conversation starts on; it joins `#entries` once its first turn names it. */
#draft: Entry<H, A> | undefined
/** Turns running in conversations this pool is not following, as a listing reported them. */
readonly #running = new Map<string, NonNullable<Conversation['runningTurn']>>()
/** The clock of the listing each conversation's row was last taken from. */
readonly #listedAt = new Map<string, number>()
/** Chats on their way out, kept until the send that withdrew them has settled. */
readonly #retiring = new Set<Entry<H, A>>()
readonly #unread = new Map<string, number>()
readonly #listeners = new Set<(state: FlowChatPoolState) => void>()
#selectedId: string | undefined
#state: FlowChatPoolState = {
selectedId: undefined,
activity: {},
unread: {},
queued: {},
mounted: [],
shownKey: ''
}
#poll: ReturnType<typeof setTimeout> | undefined
#polling = false
#pollFailures = 0
/** The listing stopped answering and its rows went quiet: it is read on, slowly, so they
* come back when it answers again. */
#recovering = false
#clock = 0
#destroyed = false
constructor(options: FlowChatPoolOptions<H, A>) {
this.#options = options
this.newChat()
}
getState = (): FlowChatPoolState => this.#state
subscribe = (listener: (state: FlowChatPoolState) => void): (() => void) => {
this.#listeners.add(listener)
listener(this.#state)
return () => {
this.#listeners.delete(listener)
}
}
get #selectedEntry(): Entry<H, A> {
// The shown conversation is never evicted, and forgetting it shows a new chat.
return (this.#selectedId === undefined ? this.#draft : this.#entries.get(this.#selectedId))!
}
/** The chat a key in `mounted` names, while this pool still holds it. */
get(key: string): PooledChat<H> | undefined {
return this.#byKey.get(key)
}
/** Shows a new chat, reusing the one already waiting for its first message. */
newChat = (): PooledChat<H> => {
if (!this.#draft) this.#draft = this.#track(undefined)
this.#draft.lastShownAt = ++this.#clock
this.#selectedId = undefined
this.#publish()
return this.#draft
}
/** Shows a conversation, following its running turn when another page started it. */
select = (conversationId: string): void => {
let entry = this.#entries.get(conversationId)
if (!entry) {
entry = this.#track(conversationId)
this.#entries.set(conversationId, entry)
}
this.#selectedId = conversationId
entry.lastShownAt = ++this.#clock
this.#unread.delete(conversationId)
const turn = this.#running.get(conversationId)
if (turn && !entry.busy) {
this.#running.delete(conversationId)
entry.turns.resume(turn)
} else if (entry.loaded && !entry.busy) {
// Held while another conversation was shown: another tab may have written since.
// A failed read leaves the rows it holds; the next return reads again.
void entry.chat.refreshMessages().catch(() => {})
}
this.#evict()
this.#publish()
}
/** Marks a listing request; pass the mark to `setListed` with what it returns. */
listingStarted = (): number => ++this.#clock
/**
* What a listing requested at `since` said. A conversation it reports running that no
* chat here is following is watched through `listRecent` until its row stops saying so.
* Neither a turn that ended here after the request nor a row a later listing already
* reported is taken from it, whichever of the two responses lands last.
*/
setListed = (conversations: readonly ListedConversation[], since: number): void => {
for (const conversation of conversations) {
const id = conversation.id
if ((this.#listedAt.get(id) ?? 0) > since) continue
this.#listedAt.set(id, since)
const followed = this.#entries.get(id)
const stale = followed && (followed.busy || followed.settledAt > since)
if (conversation.runningTurn && !stale) this.#running.set(id, conversation.runningTurn)
else this.#running.delete(id)
}
// The shown conversation follows its turn now rather than waiting for a poll to end it.
if (this.#selectedId !== undefined && this.#running.has(this.#selectedId)) {
this.select(this.#selectedId)
}
this.#schedulePoll()
this.#publish()
}
/** Drops a conversation that no longer exists; a new chat is shown in its place. */
forget = (conversationId: string): void => {
const entry = this.#entries.get(conversationId)
if (entry) this.#release(entry)
this.#entries.delete(conversationId)
this.#running.delete(conversationId)
this.#unread.delete(conversationId)
if (this.#selectedId === conversationId) this.newChat()
else this.#publish()
}
destroy = (): void => {
this.#destroyed = true
clearTimeout(this.#poll)
for (const entry of this.#entries.values()) this.#release(entry)
for (const entry of this.#retiring) this.#release(entry)
if (this.#draft) this.#release(this.#draft)
this.#retiring.clear()
this.#entries.clear()
this.#draft = undefined
this.#listeners.clear()
}
#track(conversationId: string | undefined): Entry<H, A> {
const chat = this.#options.createChat()
// Selected before the host exists: a host treats a change of conversation as the
// reader leaving one, and this chat never leaves its conversation.
if (conversationId !== undefined) void chat.selectConversation(conversationId)
const turns = new ConversationTurns<A>(chat, () => entry.host)
const entry: Entry<H, A> = {
key: `chat-${++panelKeys}`,
chat,
turns,
host: this.#options.createHost(turns),
unsubscribe: () => {},
lastShownAt: ++this.#clock,
counted: new Set(),
loaded: conversationId === undefined,
busy: false,
settledAt: 0
}
this.#byKey.set(entry.key, entry)
const unsubscribeChat = chat.subscribe((state) => this.#onChatState(entry, state))
// What waits in a chat decides whether it may be released, and marks its row.
const unsubscribeTurns = turns.subscribe(() => {
if (!this.#destroyed) this.#publish()
})
entry.unsubscribe = () => {
unsubscribeChat()
unsubscribeTurns()
}
return entry
}
#onChatState(entry: Entry<H, A>, state: ChatState): void {
if (this.#destroyed) return
if (entry === this.#draft && state.conversationId !== undefined) {
// The new chat's first turn named its conversation.
this.#draft = undefined
this.#entries.set(state.conversationId, entry)
if (this.#selectedId === undefined) this.#selectedId = state.conversationId
}
const id = state.conversationId
if (id === undefined) {
// The chat gave its conversation back: a new chat's first message never ran, so the
// conversation the id named was never created. Held under that id, the entry would
// answer for a conversation that does not exist and mint a second one on the next
// message, so it goes back to being the chat a new conversation starts on.
if (entry !== this.#draft) this.#undoNewConversation(entry)
return
}
const busy = isBusy(state.status)
if (busy) this.#running.delete(id)
else if (entry.busy) entry.settledAt = ++this.#clock
entry.busy = busy
const shown = id === this.#selectedId
for (const message of state.messages) {
if (message.role !== 'assistant' || message.pending || entry.counted.has(message.id)) continue
entry.counted.add(message.id)
if (!shown && entry.loaded && !state.loadingMessages) {
this.#unread.set(id, (this.#unread.get(id) ?? 0) + 1)
}
}
if (!state.loadingMessages) entry.loaded = true
if (!busy) this.#evict()
this.#publish()
}
/** Takes an entry back out of the list of conversations, as the chat that starts one. */
#undoNewConversation(entry: Entry<H, A>): void {
// Whether the reader is looking at this chat, read before the id it was selected under
// goes: it decides which of two chats with nothing to show stays in front of them.
let wasShown = false
for (const [key, held] of this.#entries) {
if (held !== entry) continue
this.#entries.delete(key)
this.#unread.delete(key)
this.#running.delete(key)
if (this.#selectedId === key) {
this.#selectedId = undefined
wasShown = true
}
}
if (!this.#draft) {
this.#draft = entry
this.#publish()
return
}
// A new chat opened meanwhile, and only one chat can be the one a new conversation
// starts on. The chat whose composer was written in is the one kept, since its panel
// is the only place that text exists; where both were, the one the reader has in
// front of them stays.
const opened = this.#draft
const written = this.#options.holdsDraft(entry.host)
const openedWritten = this.#options.holdsDraft(opened.host)
const kept = written === openedWritten ? (wasShown ? entry : opened) : written ? entry : opened
const retired = kept === entry ? opened : entry
this.#draft = kept
// The other is released only once the send that withdrew this conversation has
// reported what it could not do — the refusal reaches it after this — and the message
// it hands back goes to the chat kept.
this.#retiring.add(retired)
setTimeout(() => {
if (this.#destroyed || !this.#retiring.delete(retired)) return
kept.turns.adopt(retired.turns.takeHeld())
this.#release(retired)
this.#publish()
}, 0)
this.#publish()
}
#activity(id: string): ConversationActivity {
const state = this.#entries.get(id)?.chat.getState()
if (this.#running.has(id) || (state && isBusy(state.status))) return 'running'
if (state && (state.status === 'error' || lastTurnFailed(state.messages))) return 'error'
return 'idle'
}
/** Settled chats past the budget go, least recently shown first; their unread count stays. */
#evict(): void {
const settled = [...this.#entries.entries()].filter(
([id, entry]) =>
id !== this.#selectedId &&
!isBusy(entry.chat.getState().status) &&
entry.turns.releasable &&
!this.#options.holdsDraft(entry.host)
)
settled.sort(([, a], [, b]) => b.lastShownAt - a.lastShownAt)
for (const [id, entry] of settled.slice(this.#options.keepSettled ?? 5)) {
this.#release(entry)
this.#entries.delete(id)
}
}
#release(entry: Entry<H, A>): void {
this.#byKey.delete(entry.key)
entry.unsubscribe()
this.#options.disposeHost(entry.host)
entry.turns.dispose()
entry.chat.destroy()
}
#schedulePoll(): void {
const every = this.#options.pollMs ?? 3000
if (this.#running.size === 0 && !this.#recovering) {
clearTimeout(this.#poll)
this.#poll = undefined
return
}
if (this.#poll || this.#polling) return
this.#poll = setTimeout(
() => void this.#relist(),
this.#running.size === 0 ? every * RECOVERY_SLOWDOWN : every
)
}
/** One listing for every running row this pool follows no chat for. */
async #relist(): Promise<void> {
this.#poll = undefined
this.#polling = true
const since = this.listingStarted()
const watched = [...this.#running.keys()]
const rows: ListedConversation[] = []
let failed = false
try {
// Page 1 holds the running rows but for a turn that has written nothing for a while,
// behind conversations active since: the pages after it are read until every watched
// row is found or the listing ends.
for (let page = 1; page <= POLL_PAGES; page++) {
const batch = await this.#options.listRecent(page)
rows.push(...batch)
const seen = new Set(rows.map((row) => row.id))
// Recovering, this pool watches no row yet: the listing is read to its end (or to
// the page budget), since a turn that is running can be on any of those pages.
if (batch.length === 0) break
if (watched.length > 0 && watched.every((id) => seen.has(id))) break
}
this.#pollFailures = 0
} catch {
failed = true
// A listing that keeps failing would otherwise keep rows running for the life of the
// page. After a few tries the rows go quiet, and the listing is read on at a slower
// cadence so they come back once it answers again.
if (++this.#pollFailures >= POLL_GIVE_UP) {
this.#running.clear()
this.#pollFailures = 0
this.#recovering = true
}
} finally {
this.#polling = false
}
if (this.#destroyed) return
if (!failed) {
this.#recovering = false
// A watched row on no page read is gone from the listing, or has been quiet while
// more conversations than those pages hold were active: either way it goes quiet
// here, and opening it reads its own rows. A later listing that reported it stands.
const seen = new Set(rows.map((row) => row.id))
for (const id of watched) {
if (!seen.has(id) && (this.#listedAt.get(id) ?? 0) <= since) this.#running.delete(id)
}
this.setListed(rows, since)
return
}
this.#schedulePoll()
this.#publish()
}
#publish(): void {
if (this.#destroyed) return
const ids = new Set([...this.#entries.keys(), ...this.#running.keys(), ...this.#unread.keys()])
const activity: Record<string, ConversationActivity> = {}
for (const id of ids) {
const value = this.#activity(id)
if (value !== 'idle') activity[id] = value
}
const queued: Record<string, true> = {}
for (const [id, entry] of this.#entries) {
if (entry.turns.queued.text) queued[id] = true
}
// In the order the chats were created — `#byKey` insertion order — and never in the
// order they were shown: a key that moves in the `{#each}` takes its panel out of the
// DOM and back in, which loses the transcript's scroll and the composer's selection.
const withPanel = new Set(this.#entries.values())
if (this.#draft) withPanel.add(this.#draft)
const mounted = [...this.#byKey.values()]
.filter((entry) => withPanel.has(entry))
.map((entry) => entry.key)
this.#state = {
selectedId: this.#selectedId,
activity,
unread: Object.fromEntries(this.#unread),
queued,
mounted,
shownKey: this.#selectedEntry.key
}
for (const listener of this.#listeners) listener(this.#state)
}
}
@@ -17,3 +17,34 @@ export const FRAME_CLASS: Record<ChatFrame, string> = {
export function chatFlowKey(props: { path: string; identity?: string }): string {
return props.identity || props.path
}
const INPUTS_STORAGE_PREFIX = 'windmill_flow_chat_inputs_'
/**
* The flow inputs the reader chose — the model among them. They belong to the flow and are
* shared by every conversation in it, so they are held once per chat rather than once per
* panel: two panels reading storage on their own would each keep the value it read.
*/
export function loadFlowChatInputs(props: {
path: string
identity?: string
}): Record<string, any> {
try {
const stored = localStorage.getItem(`${INPUTS_STORAGE_PREFIX}${chatFlowKey(props)}`)
return stored ? JSON.parse(stored) : {}
} catch (e) {
console.error('Failed to load inputs from localStorage:', e)
return {}
}
}
export function saveFlowChatInputs(
props: { path: string; identity?: string },
values: Record<string, any>
): void {
try {
localStorage.setItem(`${INPUTS_STORAGE_PREFIX}${chatFlowKey(props)}`, JSON.stringify(values))
} catch (e) {
console.error('Failed to save inputs to localStorage:', e)
}
}
@@ -1,4 +1,19 @@
import type { AttachmentUpload, Chat, ChatMessage, ChatState } from 'windmill-chat'
import { untrack } from 'svelte'
import type {
AttachmentUpload,
Chat,
ChatMessage,
ChatState,
SendMessageOptions
} from 'windmill-chat'
import {
emptyDraft,
isBusy,
turnFailed,
type ConversationTurns,
type Draft,
type DraftSender
} from './conversationTurns'
import type {
ChatSendRequestOptions,
ChatViewHost
@@ -43,19 +58,49 @@ export type FlowChatViewHostOptions = {
revealOptions?: Pick<TypewriterRevealOptions, 'instant' | 'now' | 'schedule' | 'cancel'>
}
function isBusy(status: ChatState['status']): boolean {
return status === 'submitted' || status === 'streaming'
}
/** The chat's own signal for a send stopped before it ran; nothing to tell the reader. */
function isAbort(e: unknown): boolean {
return e instanceof Error && e.name === 'AbortError'
}
type Queue = { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }
/** What the composer attaches to a message, kept in the order it was attached. */
export type ComposerAttachment =
| { image: AttachedImage }
| { blob: AttachedBlob }
| { file: AttachedTextFile }
function emptyQueue(): Queue {
return { text: '', images: [], blobs: [] }
export function composerDraft(
text: string,
images: AttachedImage[] = [],
blobs: AttachedBlob[] = [],
files: AttachedTextFile[] = []
): Draft<ComposerAttachment> {
return {
text,
attachments: [
...images.map((image) => ({ image })),
...blobs.map((blob) => ({ blob })),
...files.map((file) => ({ file }))
]
}
}
/** This chat forwards files verbatim (`attachmentsAsBlobs`), so the text-file lane stays
* empty — it is carried anyway, so a chat that decodes them loses nothing. */
function splitAttachments(attachments: readonly ComposerAttachment[]): {
images: AttachedImage[]
blobs: AttachedBlob[]
files: AttachedTextFile[]
} {
const images: AttachedImage[] = []
const blobs: AttachedBlob[] = []
const files: AttachedTextFile[] = []
for (const attachment of attachments) {
if ('image' in attachment) images.push(attachment.image)
else if ('blob' in attachment) blobs.push(attachment.blob)
else files.push(attachment.file)
}
return { images, blobs, files }
}
/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */
@@ -68,29 +113,6 @@ function parseToolPayload(raw: string | undefined): unknown {
}
}
/**
* Whether the turn the user message at `index` started failed: its last row before the
* next user message reports `success: false`. The last row, not any row: a tool call can
* fail and the agent still answer, and that turn completed.
*/
export function turnFailed(messages: readonly ChatMessage[], index: number): boolean {
let last: ChatMessage | undefined
for (let i = index + 1; i < messages.length; i++) {
const message = messages[i]
if (message.role === 'user') break
last = message
}
return last?.success === false
}
/** Whether the latest turn failed, per `turnFailed`. False before any turn. */
function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') return turnFailed(messages, i)
}
return false
}
/**
* The step name says which AI agent step wrote a message, so it only tells the reader
* anything once the conversation holds more than one. Counted over the transcript rather
@@ -300,29 +322,44 @@ type RevealLanes = {
* makes, so what it can offer is what the SDK's `Chat` can: a message in, an answer
* streamed back, Stop. Every copilot-only field is answered with "no" (see ChatViewHost).
*
* The host owns its subscription to the chat, so a panel swapping chats mounts a new one.
* One host per conversation, living as long as its chat. Its sends go through the
* conversation's turns, which hold what waits for the running turn; the host builds each
* send and puts what a turn hands back into the composer showing it.
*/
export class FlowChatViewHost implements ChatViewHost {
export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttachment> {
#chat: Chat
#turns: ConversationTurns<ComposerAttachment>
#options: FlowChatViewHostOptions
#state = $state.raw<ChatState>() as ChatState
#queued = $state.raw<Draft<ComposerAttachment>>(emptyDraft())
#queuedLanes = $derived(splitAttachments(this.#queued.attachments))
#unsubscribe: () => void
constructor(chat: Chat, options: FlowChatViewHostOptions = {}) {
this.#chat = chat
constructor(turns: ConversationTurns<ComposerAttachment>, options: FlowChatViewHostOptions = {}) {
this.#turns = turns
this.#chat = turns.chat
this.#options = options
this.#state = this.#chat.getState()
const unsubscribeChat = this.#chat.subscribe((state) => this.#onState(state))
const unsubscribeTurns = turns.subscribe(() => {
this.#queued = turns.queued
this.#takeReturned()
})
this.#unsubscribe = () => {
unsubscribeChat()
unsubscribeTurns()
}
}
/** Set by the panel showing this conversation; a message queued here is sent with what
* the last panel to show it read. */
setOptions(options: FlowChatViewHostOptions) {
this.#options = options
this.#state = chat.getState()
this.#unsubscribe = chat.subscribe((state) => this.#onState(state))
}
#disposed = false
/** Stops following the chat, and drops what was queued: a flush still waiting on the
* turn's release would otherwise start a run from a panel that is gone. A send still
* uploading its attachments stops with the chat, which is the caller's to destroy; its
* draft is not handed back, since the composer it came from is gone too. */
dispose() {
this.#disposed = true
this.#queue = emptyQueue()
this.#unsubscribe()
for (const id of Object.keys(this.#reveals)) this.#dropReveal(id)
}
@@ -345,23 +382,9 @@ export class FlowChatViewHost implements ChatViewHost {
if (previous.conversationId !== state.conversationId) {
// A conversation opens at its end, whatever the reader was doing in the last one.
this.#automaticScroll = true
// The queue was typed into the conversation that just went away; a message sent
// after the switch would ride out of the wrong one, so it goes back to the composer.
this.dequeueMessage()
return
}
if (!isBusy(previous.status) && isBusy(state.status)) this.#turnsStarted++
if (isBusy(previous.status) && !isBusy(state.status)) {
// The turn settled. What was typed during it goes out once the turn is released,
// not now: the chat publishes `idle` from inside its own `sendMessage`, which still
// counts the turn as open until it returns, and a send made before that would be
// refused as a second turn. After a failure it goes back to the composer instead,
// where the reader would rather look at the error than pile on. A failed flow
// settles as `idle` too, with its error as the answer, so the messages decide.
const succeeded = state.status === 'idle' && !lastTurnFailed(state.messages)
if (succeeded) void this.#turnDone.then(this.flushQueuedMessage)
else this.dequeueMessage()
}
}
// Smooth streaming. The chat appends each delta to the pending assistant message as it
@@ -469,37 +492,27 @@ export class FlowChatViewHost implements ChatViewHost {
instructions = ''
// The user message lands in the transcript before `sendMessage` awaits anything.
sendInFlight = false
sendRequest = async (options: ChatSendRequestOptions = {}): Promise<boolean> =>
this.#turns.send(composerDraft(options.instructions ?? '', options.images, options.blobs))
/**
* `replayInputs` are a failed turn's own run arguments, read back from its job. They
* stand in for the composer's current inputs, so a retry runs the turn that failed
* rather than a new one wearing its text.
*/
sendRequest = async (
options: ChatSendRequestOptions = {},
replayInputs?: Record<string, any>
): Promise<boolean> => {
const text = options.instructions?.trim() ?? ''
let images = options.images ?? []
let blobs = options.blobs ?? []
// The composer refuses an attachment-only send (requiresMessageText), so this is
// the same rule at the other end: nothing runs without a message.
if (!text) return false
if (this.loading) {
this.queueMessage(text, images, undefined, undefined, blobs)
return true
}
if (this.#options.sendDisabled?.()) {
// Refused, not dropped: the draft waits in the composer for sending to reopen.
this.#aiChatInput?.prependText(text, images, [], blobs)
return false
}
prepareSend(
draft: Draft<ComposerAttachment>,
replayInputs?: Record<string, unknown>
): SendMessageOptions | undefined {
// Refused, not dropped: the draft waits in the composer for sending to reopen.
if (this.#options.sendDisabled?.()) return undefined
let { images, blobs } = splitAttachments(draft.attachments)
const target = this.#options.attachmentsTarget?.()
// The inputs modal does not ask for this input, so a required one is enforced here. A
// replay carries the files its run already has, in `replayInputs`, and attaches none.
if (!replayInputs && target?.required && images.length === 0 && blobs.length === 0) {
sendUserToast('This chat needs a file with each message. Attach one to send.', true)
this.#aiChatInput?.prependText(text, images, [], blobs)
return false
return undefined
}
// A replay sends the arguments its run had, attachment references included.
const inputs = replayInputs ?? { ...(this.#options.additionalInputs?.() ?? {}) }
@@ -528,41 +541,24 @@ export class FlowChatViewHost implements ChatViewHost {
}))
: []
this.#automaticScroll = true
// A run that fails is reported through the chat's `onError` and as a failed message;
// the promise itself only rejects when the chat refuses the turn outright — a turn
// already running, an upload that failed, Stop pressed while it ran — and the draft
// is then handed back rather than dropped. The composer took it before calling, so
// nothing else would.
const turn = this.#chat
.sendMessage(text, {
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
attachments,
attachmentsInput: target
})
.catch((e) => {
if (this.#disposed) return
if (attachments.length > 0 && !isAbort(e)) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
}
// What was queued behind it comes back too, after it: the chat publishes `idle`
// when it withdraws the turn, and a queue left in place would be flushed as if
// the turn had run.
this.dequeueMessage()
this.#aiChatInput?.prependText(text, images, [], blobs)
})
this.#turnDone = turn
await turn
return true
return {
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
attachments,
attachmentsInput: target
}
}
/** Settles when the chat has released the last turn this host started. */
#turnDone: Promise<unknown> = Promise.resolve()
sendFailed(error: unknown, draft: Draft<ComposerAttachment>): void {
const uploaded = draft.attachments.length > 0 && this.#options.attachmentsTarget?.()
if (uploaded && !isAbort(error)) {
sendUserToast(
`Could not upload the attachments: ${error instanceof Error ? error.message : String(error)}`,
true
)
}
}
cancel = () => {
// Stop means stop: what was typed during the run goes back to the composer rather
// than waiting there to go out after some later turn settles.
this.dequeueMessage()
const { messages, status } = this.#state
const turn = isBusy(status) ? [...messages].reverse().find((m) => m.role === 'user') : undefined
if (turn) {
@@ -570,66 +566,72 @@ export class FlowChatViewHost implements ChatViewHost {
[...this.#stoppedTurns, turn.id, turn.serverId].filter((id) => id !== undefined)
)
}
void this.#chat.stop()
this.#turns.stop()
}
// Typed off the interface: a Svelte component's own type resolves differently
// across import specifiers, and the two would then not be assignable.
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => {
this.#aiChatInput = aiChatInput
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) =>
// Called from the composer's mount effect, and taking what a turn handed back reads the
// draft it writes: tracked, that would rerun the effect and hand it back again.
untrack(() => {
this.#aiChatInput = aiChatInput
this.#takeReturned()
})
/** Whether a composer showing this conversation holds something not sent — text, an
* attachment, a file still being read. Its panel stays mounted for as long as one does:
* nothing else holds that draft. */
holdsDraft(): boolean {
return this.#composersWithDraft.size > 0
}
/** Puts what a turn handed back into the composer, when one shows this conversation;
* until then the conversation's turns hold it. */
#takeReturned() {
if (!this.#aiChatInput || this.#disposed) return
const { text, attachments } = this.#turns.takeReturned()
if (!text && attachments.length === 0) return
const { images, blobs, files } = splitAttachments(attachments)
this.#aiChatInput.prependText(text, images, files, blobs)
}
// One message typed while the turn runs, sent whole with its attachments once the turn
// settles. Enter again appends a line rather than replacing what waits.
#queue = $state<Queue>(emptyQueue())
get queuedMessage(): string {
return this.#queue.text
return this.#queued.text
}
queuedContext = undefined
get queuedImages(): AttachedImage[] {
return this.#queue.images
return this.#queuedLanes.images
}
get queuedFiles(): AttachedTextFile[] {
return this.#queuedLanes.files
}
queuedFiles: AttachedTextFile[] = []
get queuedBlobs(): AttachedBlob[] {
return this.#queue.blobs
return this.#queuedLanes.blobs
}
queueMessage = (
text: string,
images: AttachedImage[] = [],
_context?: unknown,
_files?: unknown,
files: AttachedTextFile[] = [],
blobs: AttachedBlob[] = []
) => {
const trimmed = text.trim()
if (!trimmed && images.length === 0 && blobs.length === 0) return
const queue = this.#queue
this.#queue = {
text: !trimmed ? queue.text : queue.text ? `${queue.text}\n${trimmed}` : trimmed,
images: [...queue.images, ...images],
blobs: [...queue.blobs, ...blobs]
}
this.#turns.queue(composerDraft(text, images, blobs, files))
}
/** Put the queued draft back in the composer, attachments included. */
dequeueMessage = () => {
const { text, images, blobs } = this.#takeQueue()
if (!text && images.length === 0 && blobs.length === 0) return
this.#aiChatInput?.prependText(text, images, [], blobs)
}
flushQueuedMessage = () => {
// Same rule as sendRequest, read before the queue is drained: a turn with no message
// cannot run, and taking the queue for it would drop the attachments on the floor.
if (!this.#queue.text || this.#disposed) return
const { text, images, blobs } = this.#takeQueue()
void this.sendRequest({ instructions: text, images, blobs })
}
#takeQueue(): Queue {
const taken = this.#queue
this.#queue = emptyQueue()
return taken
this.#turns.dequeue()
}
setComposerStaged = () => {}
setComposerHasDraft = () => {}
clearComposerStaged = () => {}
/** Plain, not `$state`: read by the pool deciding what to release, never rendered. */
readonly #composersWithDraft = new Set<string>()
setComposerHasDraft = (key: string, hasDraft: boolean) => {
if (hasDraft) this.#composersWithDraft.add(key)
else this.#composersWithDraft.delete(key)
}
clearComposerStaged = (key: string) => {
this.#composersWithDraft.delete(key)
}
attachmentBytesExcluding = () => 0
// Per-message actions
@@ -689,7 +691,7 @@ export class FlowChatViewHost implements ChatViewHost {
sendUserToast('That chat started another turn. Retry once it finishes.', true)
return
}
void this.sendRequest({ instructions: message.content }, replayInputs)
void this.#turns.send(composerDraft(message.content), replayInputs)
}
restartGeneration = () => {}
handleUserQuestionAnswer = () => false
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte'
import { TurnRunningError, type Chat, type ChatMessage, type ChatState } from 'windmill-chat'
import {
FlowChatViewHost,
toDisplayMessages,
type ComposerAttachment,
type FlowChatViewHostOptions
} from './flowChatViewHost.svelte'
import { ConversationTurns } from './conversationTurns'
vi.mock('$lib/gen', () => ({
JobService: { getJobArgs: vi.fn() }
@@ -59,13 +65,15 @@ function fakeChat(initial: ChatState = idleState()) {
return () => listeners.delete(listener)
},
sendMessage: vi.fn(async () => {}),
resumeTurn: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
newConversation: vi.fn(),
selectConversation: vi.fn(async () => {}),
loadConversations: vi.fn(async () => []),
deleteConversation: vi.fn(async () => {}),
loadOlderMessages: vi.fn(async () => {}),
renameConversation: vi.fn(async () => {}),
loadOlderMessages: vi.fn(async () => {}),
refreshMessages: vi.fn(async () => {}),
destroy: vi.fn()
} satisfies Chat
return { chat, set }
@@ -73,6 +81,14 @@ function fakeChat(initial: ChatState = idleState()) {
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
/** A host on its conversation's turns, the way the pool pairs them. */
function hostOn(chat: Chat, options?: FlowChatViewHostOptions) {
let host!: FlowChatViewHost
const turns = new ConversationTurns<ComposerAttachment>(chat, () => host)
host = new FlowChatViewHost(turns, options)
return Object.assign(host, { turns })
}
describe('toDisplayMessages', () => {
it('maps user, assistant and tool rows, marking the user message of a failed turn', () => {
const rows = [
@@ -206,7 +222,7 @@ describe('toDisplayMessages', () => {
expect(display[1]).not.toHaveProperty('streaming')
const { chat } = fakeChat(idleState({ messages: rows }))
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
host.retryRequest(4)
await vi.waitFor(() =>
expect(chat.sendMessage).toHaveBeenCalledWith('second', expect.anything())
@@ -370,7 +386,7 @@ describe('toDisplayMessages', () => {
describe('FlowChatViewHost', () => {
it('sends the text with the additional inputs and reports loading from the status', async () => {
const { chat, set } = fakeChat()
const host = new FlowChatViewHost(chat, { additionalInputs: () => ({ tone: 'brief' }) })
const host = hostOn(chat, { additionalInputs: () => ({ tone: 'brief' }) })
expect(host.loading).toBe(false)
expect(await host.sendRequest({ instructions: ' hello ' })).toBe(true)
expect(chat.sendMessage).toHaveBeenCalledWith('hello', {
@@ -394,7 +410,7 @@ describe('FlowChatViewHost', () => {
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((resolve) => (releaseTurn = resolve))
)
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
void host.sendRequest({ instructions: 'start' })
set({ status: 'streaming' })
host.queueMessage('first')
@@ -418,7 +434,7 @@ describe('FlowChatViewHost', () => {
const { chat, set } = fakeChat(
idleState({ status: 'streaming', messages: [message({ role: 'user', content: 'go' })] })
)
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
host.queueMessage('later')
@@ -438,7 +454,7 @@ describe('FlowChatViewHost', () => {
it('hands the queue back instead of sending while sending is disabled', async () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
let deploying = false
const host = new FlowChatViewHost(chat, { sendDisabled: () => deploying })
const host = hostOn(chat, { sendDisabled: () => deploying })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
host.queueMessage('after deploy')
@@ -458,12 +474,12 @@ describe('FlowChatViewHost', () => {
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((resolve) => (releaseTurn = resolve))
)
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
void host.sendRequest({ instructions: 'start' })
set({ status: 'streaming' })
host.queueMessage('never')
set({ status: 'idle' })
host.dispose()
host.turns.dispose()
releaseTurn()
await flush()
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
@@ -472,7 +488,7 @@ describe('FlowChatViewHost', () => {
it('hands the text back when the chat refuses the turn', async () => {
const { chat } = fakeChat()
chat.sendMessage.mockRejectedValueOnce(new Error('a message is already being answered'))
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
await host.sendRequest({ instructions: 'kept' })
@@ -480,9 +496,139 @@ describe('FlowChatViewHost', () => {
host.dispose()
})
it('follows the turn a conversation is still answering and sends the refused message after it', async () => {
const { chat, set } = fakeChat()
const turn = { jobId: 'job-9', userSeq: 41 }
chat.sendMessage.mockRejectedValueOnce(new TurnRunningError('still answering', turn))
let releaseResumed = () => {}
chat.resumeTurn.mockImplementationOnce(
() => new Promise<void>((resolve) => (releaseResumed = resolve))
)
const host = hostOn(chat)
await host.sendRequest({ instructions: 'after it' })
expect(chat.resumeTurn).toHaveBeenCalledWith(turn)
expect(host.queuedMessage).toBe('after it')
set({ status: 'streaming' })
set({ status: 'idle' })
releaseResumed()
await new Promise((resolve) => setTimeout(resolve, 0))
// The refused send is the first call; the flush after the resumed turn is the second.
expect(chat.sendMessage).toHaveBeenCalledTimes(2)
expect(chat.sendMessage).toHaveBeenLastCalledWith('after it', {
inputs: undefined,
attachments: [],
attachmentsInput: undefined
})
host.dispose()
})
it('hands back a refused message ahead of its queued correction when no composer shows it', async () => {
const { chat, set } = fakeChat()
let refuse = (_e: Error) => {}
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
const host = hostOn(chat)
const sent = host.sendRequest({ instructions: 'first' })
set({ status: 'submitted' })
host.queueMessage('correction')
set({ status: 'idle' })
refuse(new Error('upload failed (500)'))
await sent
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
expect(prependText).toHaveBeenCalledWith('first\ncorrection', [], [], [])
host.dispose()
})
it('hands a refused message back when the chat cannot follow the running turn', async () => {
const { chat } = fakeChat(idleState({ history: 'local' }))
chat.sendMessage.mockRejectedValueOnce(
new TurnRunningError('still answering', { jobId: 'job-9', userSeq: 41 })
)
const host = hostOn(chat)
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
await host.sendRequest({ instructions: 'again' })
await flush()
expect(chat.resumeTurn).not.toHaveBeenCalled()
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
expect(prependText).toHaveBeenCalledWith('again', [], [], [])
host.dispose()
})
it('keeps a refused message ahead of what was queued while it waited', async () => {
const { chat, set } = fakeChat()
let refuse = (_e: Error) => {}
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
// Like the chat, following a turn holds it at once.
chat.resumeTurn.mockImplementationOnce(async () => set({ status: 'submitted' }))
const host = hostOn(chat)
const sent = host.sendRequest({ instructions: 'first' })
set({ status: 'submitted' })
host.queueMessage('correction')
set({ status: 'idle' })
refuse(new TurnRunningError('still answering', { jobId: 'job-9', userSeq: 41 }))
await sent
expect(host.queuedMessage).toBe('first\ncorrection')
host.dispose()
})
it('hands back a message stopped before the chat refused it, rather than queueing it', async () => {
const { chat } = fakeChat()
let refuse = (_e: Error) => {}
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
const host = hostOn(chat)
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
const sent = host.sendRequest({ instructions: 'never mind' })
host.cancel()
refuse(new TurnRunningError('still answering', { jobId: 'job-9', userSeq: 41 }))
await sent
expect(chat.resumeTurn).not.toHaveBeenCalled()
expect(host.queuedMessage).toBe('')
expect(prependText).toHaveBeenCalledWith('never mind', [], [], [])
host.dispose()
})
it('keeps text handed back while no composer is mounted for the next one', () => {
const { chat } = fakeChat(idleState({ status: 'streaming' }))
const host = hostOn(chat)
host.queueMessage('typed before leaving')
host.cancel()
// Held by the conversation's turns alone until a composer takes it, so the pool must
// not release it.
expect(host.turns.holdsText).toBe(true)
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
expect(prependText).toHaveBeenCalledWith('typed before leaving', [], [], [])
expect(host.turns.holdsText).toBe(false)
host.dispose()
})
it('reports a composer holding a draft, so its conversation keeps its panel', () => {
const { chat } = fakeChat()
const host = hostOn(chat)
expect(host.holdsDraft()).toBe(false)
// Two composers can show one conversation at once: the message being edited and the
// one being written. The panel stays for as long as either holds something.
host.setComposerHasDraft('composer', true)
host.setComposerHasDraft('edit-box', true)
expect(host.holdsDraft()).toBe(true)
host.setComposerHasDraft('composer', false)
expect(host.holdsDraft()).toBe(true)
host.clearComposerStaged('edit-box')
expect(host.holdsDraft()).toBe(false)
host.dispose()
})
it('hands the queue back to the composer on Stop and on a failed turn', async () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
host.queueMessage('later')
@@ -510,12 +656,12 @@ describe('FlowChatViewHost', () => {
it('takes attachments only where the flow has an input for them', () => {
const { chat } = fakeChat()
const none = new FlowChatViewHost(chat)
const none = hostOn(chat)
expect(none.supportsMessageAttachments).toBe(false)
const list = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const list = hostOn(chat, { attachmentsTarget: () => listInput })
expect(list.supportsMessageAttachments).toBe(true)
expect(list.maxMessageAttachments).toBeUndefined()
const single = new FlowChatViewHost(chat, {
const single = hostOn(chat, {
attachmentsTarget: () => ({ name: 'file', multiple: false }),
attachmentsUnavailable: () => 'no storage'
})
@@ -525,7 +671,7 @@ describe('FlowChatViewHost', () => {
it('hands the attachments to the chat, and drops a stored value for their input', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
const host = hostOn(chat, {
additionalInputs: () => ({ tone: 'brief', files: [{ s3: 'stale' }] }),
attachmentsTarget: () => listInput
})
@@ -542,7 +688,7 @@ describe('FlowChatViewHost', () => {
// A queue merged over several turns reaches the host as one send.
it('re-applies a single-file cap to a merged queue', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
const host = hostOn(chat, {
attachmentsTarget: () => ({ name: 'file', multiple: false })
})
await host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
@@ -552,7 +698,7 @@ describe('FlowChatViewHost', () => {
it('hands the draft back with its attachments when the upload is refused or stopped', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const host = hostOn(chat, { attachmentsTarget: () => listInput })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
chat.sendMessage.mockRejectedValueOnce(new Error('POST upload failed (500)'))
@@ -574,7 +720,7 @@ describe('FlowChatViewHost', () => {
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const host = hostOn(chat, { attachmentsTarget: () => listInput })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
void host.sendRequest({ instructions: 'A', blobs: [pdf] })
@@ -603,7 +749,7 @@ describe('FlowChatViewHost', () => {
user_message: 'read',
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
} as any)
const host = new FlowChatViewHost(chat, {
const host = hostOn(chat, {
workspace: () => 'ws',
attachmentsTarget: () => listInput
})
@@ -620,7 +766,7 @@ describe('FlowChatViewHost', () => {
it('refuses a message without a file when the flow requires one', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
const host = hostOn(chat, {
attachmentsTarget: () => ({ ...listInput, required: true })
})
const prependText = vi.fn()
@@ -635,7 +781,7 @@ describe('FlowChatViewHost', () => {
it('queues attachments with the text and sends them together', async () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const host = hostOn(chat, { attachmentsTarget: () => listInput })
host.queueMessage('look', [image], undefined, undefined, [pdf])
expect(host.queuedImages).toEqual([image])
expect(host.queuedBlobs).toEqual([pdf])
@@ -661,7 +807,7 @@ describe('FlowChatViewHost', () => {
messages: [message({ id: 'live', role: 'user', content: 'go' }), failedTool]
})
)
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
host.cancel()
expect(chat.stop).toHaveBeenCalled()
set({ status: 'idle' })
@@ -703,7 +849,7 @@ describe('FlowChatViewHost', () => {
it('stops following the chat once disposed', () => {
const { chat, set } = fakeChat()
const host = new FlowChatViewHost(chat)
const host = hostOn(chat)
host.dispose()
set({ status: 'streaming' })
expect(host.loading).toBe(false)
@@ -721,7 +867,7 @@ describe('FlowChatViewHost', () => {
it("replays the turn with the inputs its run had, not the composer's", async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse', model: 'old' } as any)
const host = new FlowChatViewHost(chat, {
const host = hostOn(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief', model: 'new' }),
inputsShownInComposer: () => ['model']
@@ -736,10 +882,46 @@ describe('FlowChatViewHost', () => {
host.dispose()
})
it('keeps the replayed inputs when a turn elsewhere refuses the retry', async () => {
const { chat, set } = fakeChat(failedTurn())
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse' } as any)
const turn = { jobId: 'job-elsewhere', userSeq: 12 }
chat.sendMessage.mockRejectedValueOnce(new TurnRunningError('still answering', turn))
let releaseResumed = () => {}
chat.resumeTurn.mockImplementationOnce(
() => new Promise<void>((resolve) => (releaseResumed = resolve))
)
const host = hostOn(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief' })
})
await host.retryRequest(0)
expect(chat.resumeTurn).toHaveBeenCalledWith(turn)
// That turn answers, and the retry goes out behind it.
set({ status: 'streaming' })
set({
status: 'idle',
messages: [
...failedTurn().messages,
message({ role: 'user', content: 'from the other tab' }),
message({ role: 'assistant', content: 'done' })
]
})
releaseResumed()
await new Promise((resolve) => setTimeout(resolve, 0))
// On the arguments of the turn it replays, not on what the composer holds now.
expect(chat.sendMessage).toHaveBeenCalledTimes(2)
expect(chat.sendMessage).toHaveBeenLastCalledWith(
'go',
expect.objectContaining({ inputs: { tone: 'terse' } })
)
host.dispose()
})
it('falls back to a plain resend once the job is purged', async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 404 }))
const host = new FlowChatViewHost(chat, {
const host = hostOn(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief' })
})
@@ -755,7 +937,7 @@ describe('FlowChatViewHost', () => {
it('does nothing but say so when the run cannot be read', async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('down'), { status: 500 }))
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const host = hostOn(chat, { workspace: () => 'ws' })
await host.retryRequest(0)
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith('Could not read what that turn ran with. Try again.', true)
@@ -766,7 +948,7 @@ describe('FlowChatViewHost', () => {
const { chat, set } = fakeChat(failedTurn())
let answer = (_: unknown) => {}
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const host = hostOn(chat, { workspace: () => 'ws' })
const retried = host.retryRequest(0)
set({ status: 'streaming' })
answer({ user_message: 'go' })
@@ -785,7 +967,7 @@ describe('FlowChatViewHost', () => {
const { chat, set } = fakeChat(failedTurn())
let answer = (_: unknown) => {}
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const host = hostOn(chat, { workspace: () => 'ws' })
const retried = host.retryRequest(0)
set({ status: 'streaming' })
set({ status: 'idle' })
@@ -826,7 +1008,7 @@ describe('FlowChatViewHost', () => {
it('paces a streaming answer and shows it whole once it settles', () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const reveal = manualReveal()
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
const host = hostOn(chat, { revealOptions: reveal.options })
const streaming = message({ role: 'assistant', id: 'a1', content: '', pending: true })
set({ messages: [{ ...streaming, content: 'The answer, in one burst of text.' }] })
const shown = () => (host.displayMessages[0] as { content: string }).content
@@ -848,7 +1030,7 @@ describe('FlowChatViewHost', () => {
it('shows a paced row whole once a tool card follows it', () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const reveal = manualReveal()
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
const host = hostOn(chat, { revealOptions: reveal.options })
const answer = message({
role: 'assistant',
id: 'a1',
@@ -53,6 +53,7 @@
resetSessionPreviewTabs
} from './sessionRuntime.svelte'
import SessionStatusDot from './SessionStatusDot.svelte'
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
import SessionFilterMenu from './SessionFilterMenu.svelte'
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
@@ -803,14 +804,7 @@
{#if draft}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
</span>
{/if}
<UnreadCountBadge count={unread} class="min-w-4 h-4 text-[10px]" />
</span>
{/if}
</MenuItem>
@@ -1079,14 +1073,7 @@
{#if draft}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
</span>
{/if}
<UnreadCountBadge count={unread} class="min-w-4 h-4 text-[10px]" />
</span>
{/if}
</button>
@@ -8,8 +8,18 @@
let {
status,
isFork,
forkDetached = false
}: { status: SessionChatStatus; isFork: boolean; forkDetached?: boolean } = $props()
forkDetached = false,
resting,
restingTitle
}: {
status: SessionChatStatus
isFork: boolean
forkDetached?: boolean
/** What the slot shows when there is no live signal. Sessions leave it unset and get
* the workspace/fork mark below; another list passes its own resting mark. */
resting?: import('svelte').Snippet
restingTitle?: string
} = $props()
const statusTooltip: Record<SessionChatStatus, string> = {
idle: 'No chat activity',
@@ -39,7 +49,7 @@
: 'Root workspace session'
)
const title = $derived(liveOverride ? statusTooltip[status] : persistentTitle)
const title = $derived(liveOverride ? statusTooltip[status] : (restingTitle ?? persistentTitle))
</script>
<span class="inline-flex items-center justify-center w-4 h-3 shrink-0" {title}>
@@ -55,6 +65,8 @@
<CircleHelp class="w-3 h-3 text-amber-500" />
{:else if status === 'error'}
<AlertTriangle class="w-3 h-3 text-red-500" />
{:else if resting}
{@render resting()}
{:else if isFork}
{#if forkDetached}
<GitPullRequestClosed class="w-3 h-3 text-red-500" />