mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 13:53:27 +00:00
feat: show queued dashboard follow-ups (#1631)
* feat: show queued dashboard follow-ups Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: de-dupe queued follow-ups while streaming --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
parent
db2ae58edb
commit
8e0788dc2a
8 changed files with 278 additions and 66 deletions
|
|
@ -10,6 +10,7 @@ the preceding tool result, exactly as a real model would.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from e2e_env import (
|
from e2e_env import (
|
||||||
|
|
@ -239,6 +240,11 @@ def _latest_attribution(messages: list[BaseMessage]) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _step_followup(messages: list[BaseMessage]) -> AIMessage:
|
def _step_followup(messages: list[BaseMessage]) -> AIMessage:
|
||||||
|
if any(
|
||||||
|
isinstance(msg, HumanMessage) and "Please queue this follow-up" in _text(msg.content)
|
||||||
|
for msg in messages
|
||||||
|
):
|
||||||
|
time.sleep(2)
|
||||||
attribution = _latest_attribution(messages)
|
attribution = _latest_attribution(messages)
|
||||||
suffix = f" I saw this follow-up was from {attribution}." if attribution else ""
|
suffix = f" I saw this follow-up was from {attribution}." if attribution else ""
|
||||||
return AIMessage(content=f"{FOLLOW_UP_REPLY}{suffix}")
|
return AIMessage(content=f"{FOLLOW_UP_REPLY}{suffix}")
|
||||||
|
|
|
||||||
BIN
tests/e2e/screenshots/queued-messages-dashboard.png
Normal file
BIN
tests/e2e/screenshots/queued-messages-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -10,15 +10,34 @@ async function loginAs(page: Page, user: { login: string; email: string }) {
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openRunningThreadViaSlackLink(page: Page) {
|
||||||
|
await page.goto("/mock/slack");
|
||||||
|
await page.locator("#reset").click();
|
||||||
|
await expect(page.locator("#thread")).toContainText("No messages yet");
|
||||||
|
await page
|
||||||
|
.locator("#text")
|
||||||
|
.fill("<@U0BOT> please add a greet() helper and open a PR");
|
||||||
|
await page.locator("#send").click();
|
||||||
|
|
||||||
|
const webLink = page.locator('.msg.bot a[href*="/agents/"]').first();
|
||||||
|
await expect(webLink).toBeVisible();
|
||||||
|
await webLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/agents\//);
|
||||||
|
}
|
||||||
|
|
||||||
// Run the Slack flow so a thread + PR exist, then click the bot's real
|
// Run the Slack flow so a thread + PR exist, then click the bot's real
|
||||||
// "Open in Web" link, landing on the actual dashboard app.
|
// "Open in Web" link, landing on the actual dashboard app.
|
||||||
async function openThreadViaSlackLink(page: Page) {
|
async function openThreadViaSlackLink(page: Page) {
|
||||||
await page.goto("/mock/slack");
|
await page.goto("/mock/slack");
|
||||||
await page.locator("#reset").click();
|
await page.locator("#reset").click();
|
||||||
await expect(page.locator("#thread")).toContainText("No messages yet");
|
await expect(page.locator("#thread")).toContainText("No messages yet");
|
||||||
await page.locator("#text").fill("<@U0BOT> please add a greet() helper and open a PR");
|
await page
|
||||||
|
.locator("#text")
|
||||||
|
.fill("<@U0BOT> please add a greet() helper and open a PR");
|
||||||
await page.locator("#send").click();
|
await page.locator("#send").click();
|
||||||
await expect(page.locator(".msg.bot").filter({ hasText: "Add greet() helper" })).toBeVisible();
|
await expect(
|
||||||
|
page.locator(".msg.bot").filter({ hasText: "Add greet() helper" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
const webLink = page.locator('.msg.bot a[href*="/agents/"]').first();
|
const webLink = page.locator('.msg.bot a[href*="/agents/"]').first();
|
||||||
await expect(webLink).toBeVisible();
|
await expect(webLink).toBeVisible();
|
||||||
|
|
@ -38,25 +57,61 @@ async function expectTranscriptVisible(page: Page) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Slack → web handoff (real dashboard UI)", () => {
|
test.describe("Slack → web handoff (real dashboard UI)", () => {
|
||||||
test("the SAME user continues the conversation in the web app", async ({ page }) => {
|
test("the SAME user continues the conversation in the web app", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await loginAs(page, SAME_USER);
|
await loginAs(page, SAME_USER);
|
||||||
await openThreadViaSlackLink(page);
|
await openThreadViaSlackLink(page);
|
||||||
|
|
||||||
// The owner sees the composer (either the follow-up bar once the transcript
|
// The owner sees the composer (either the follow-up bar once the transcript
|
||||||
// hydrates, or the empty-state bar before it — both mean they can type).
|
// hydrates, or the empty-state bar before it — both mean they can type).
|
||||||
const composer = page.getByPlaceholder(/Add a follow up|Send the first message/);
|
const composer = page.getByPlaceholder(
|
||||||
|
/Add a follow up|Send the first message/,
|
||||||
|
);
|
||||||
await expect(composer).toBeVisible();
|
await expect(composer).toBeVisible();
|
||||||
|
|
||||||
// Continue from the web — a new agent reply streams into the same thread.
|
// Continue from the web — a new agent reply streams into the same thread.
|
||||||
await composer.fill("Looks good — can you also add a docstring?");
|
await composer.fill("Looks good — can you also add a docstring?");
|
||||||
await composer.press("Enter");
|
await composer.press("Enter");
|
||||||
await expect(page.getByText(/anything else you'd like changed/)).toBeVisible();
|
await expect(
|
||||||
|
page.getByText(/anything else you'd like changed/),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// The transcript that started in Slack is here too (incl. the PR link).
|
// The transcript that started in Slack is here too (incl. the PR link).
|
||||||
await expect(page.getByRole("link", { name: "Add greet() helper" }).first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Add greet() helper" }).first(),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a DIFFERENT user can post, and their message is attributed", async ({ page }) => {
|
test("shows follow-ups queued while the agent is still running", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await loginAs(page, SAME_USER);
|
||||||
|
await openRunningThreadViaSlackLink(page);
|
||||||
|
|
||||||
|
const queuedText = "Please queue this follow-up while you finish the PR.";
|
||||||
|
const busyComposer = page.getByPlaceholder(
|
||||||
|
"Send a message to queue next...",
|
||||||
|
);
|
||||||
|
await expect(busyComposer).toBeVisible();
|
||||||
|
await busyComposer.fill(queuedText);
|
||||||
|
await busyComposer.press("Enter");
|
||||||
|
|
||||||
|
const queuedMessage = page
|
||||||
|
.getByTestId("queued-message")
|
||||||
|
.filter({ hasText: queuedText });
|
||||||
|
await expect(queuedMessage).toBeVisible();
|
||||||
|
const screenshotPath = testInfo.outputPath("queued-messages-dashboard.png");
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
|
await testInfo.attach("queued-messages-dashboard", {
|
||||||
|
path: screenshotPath,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a DIFFERENT user can post, and their message is attributed", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await loginAs(page, OTHER_USER);
|
await loginAs(page, OTHER_USER);
|
||||||
await openThreadViaSlackLink(page);
|
await openThreadViaSlackLink(page);
|
||||||
|
|
||||||
|
|
@ -64,16 +119,22 @@ test.describe("Slack → web handoff (real dashboard UI)", () => {
|
||||||
await expectTranscriptVisible(page);
|
await expectTranscriptVisible(page);
|
||||||
|
|
||||||
// …and a non-owner now gets a composer too (owner-only restriction removed).
|
// …and a non-owner now gets a composer too (owner-only restriction removed).
|
||||||
const composer = page.getByPlaceholder(/Add a follow up|Send the first message/);
|
const composer = page.getByPlaceholder(
|
||||||
|
/Add a follow up|Send the first message/,
|
||||||
|
);
|
||||||
await expect(composer).toBeVisible();
|
await expect(composer).toBeVisible();
|
||||||
|
|
||||||
// Posting starts a new run — the agent's follow-up reply streams in.
|
// Posting starts a new run — the agent's follow-up reply streams in.
|
||||||
await composer.fill("Can you also add a docstring?");
|
await composer.fill("Can you also add a docstring?");
|
||||||
await composer.press("Enter");
|
await composer.press("Enter");
|
||||||
await expect(page.getByText(/anything else you'd like changed/)).toBeVisible();
|
await expect(
|
||||||
|
page.getByText(/anything else you'd like changed/),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// The non-owner's message is tagged server-side with their GitHub login, so
|
// The non-owner's message is tagged server-side with their GitHub login, so
|
||||||
// the owner can tell who sent it.
|
// the owner can tell who sent it.
|
||||||
await expect(page.getByText(new RegExp(`@${OTHER_USER.login}`)).first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByText(new RegExp(`@${OTHER_USER.login}`)).first(),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { Link } from "@tanstack/react-router"
|
||||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||||
import { Map as MapIcon } from "lucide-react"
|
import { Map as MapIcon } from "lucide-react"
|
||||||
|
|
||||||
import type { AgentThread, Message } from "@/lib/agents/types"
|
import type {
|
||||||
|
AgentThread,
|
||||||
|
Message,
|
||||||
|
QueuedThreadMessage,
|
||||||
|
} from "@/lib/agents/types"
|
||||||
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import {
|
import {
|
||||||
AgentGitPanel,
|
AgentGitPanel,
|
||||||
|
|
@ -24,6 +28,44 @@ interface AgentThreadViewProps {
|
||||||
thread: AgentThread
|
thread: AgentThread
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageText(message: Message): string {
|
||||||
|
return message.chunks
|
||||||
|
.map((chunk) => (chunk.kind === "text" ? chunk.text : ""))
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleQueuedMessages(
|
||||||
|
queuedMessages: Array<QueuedThreadMessage> | undefined,
|
||||||
|
messages: Array<Message>
|
||||||
|
): Array<QueuedThreadMessage> {
|
||||||
|
const queued = queuedMessages ?? []
|
||||||
|
if (queued.length === 0) return queued
|
||||||
|
|
||||||
|
const userMessages = messages
|
||||||
|
.filter((message) => message.author === "user")
|
||||||
|
.map((message) => ({
|
||||||
|
text: messageText(message),
|
||||||
|
timestamp: Date.parse(message.timestamp),
|
||||||
|
consumed: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return queued.filter((queuedMessage) => {
|
||||||
|
const queuedText = queuedMessage.content.trim()
|
||||||
|
if (!queuedText) return true
|
||||||
|
|
||||||
|
const match = userMessages.find((message) => {
|
||||||
|
if (message.consumed || !message.text.includes(queuedText)) return false
|
||||||
|
if (!Number.isFinite(message.timestamp)) return true
|
||||||
|
return message.timestamp >= queuedMessage.createdAt - 1000
|
||||||
|
})
|
||||||
|
if (!match) return true
|
||||||
|
|
||||||
|
match.consumed = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The stream lives at the `/agents` layout (one persistent provider that
|
// The stream lives at the `/agents` layout (one persistent provider that
|
||||||
// survives the home → thread navigation), so this view only consumes it.
|
// survives the home → thread navigation), so this view only consumes it.
|
||||||
export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
|
|
@ -71,8 +113,13 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
return live
|
return live
|
||||||
}, [stream.messages, stream.toolCalls, stream.subagents, thread.messages])
|
}, [stream.messages, stream.toolCalls, stream.subagents, thread.messages])
|
||||||
|
|
||||||
const hasMessages = baseMessages.length > 0
|
|
||||||
const isStreaming = thread.status === "running" || stream.isLoading
|
const isStreaming = thread.status === "running" || stream.isLoading
|
||||||
|
const queuedMessages = useMemo(
|
||||||
|
() => visibleQueuedMessages(thread.queuedMessages, baseMessages),
|
||||||
|
[baseMessages, thread.queuedMessages]
|
||||||
|
)
|
||||||
|
const hasMessages = baseMessages.length > 0
|
||||||
|
const hasConversation = hasMessages || queuedMessages.length > 0
|
||||||
const isThinking = stream.isLoading
|
const isThinking = stream.isLoading
|
||||||
const settingUpSandbox = isThinking && baseMessages.length === 0
|
const settingUpSandbox = isThinking && baseMessages.length === 0
|
||||||
// The transcript hydrates from the SDK (`GET …/state` → `stream.messages`).
|
// The transcript hydrates from the SDK (`GET …/state` → `stream.messages`).
|
||||||
|
|
@ -118,10 +165,11 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{hasMessages ? (
|
{hasConversation ? (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Messages
|
<Messages
|
||||||
messages={baseMessages}
|
messages={baseMessages}
|
||||||
|
queuedMessages={queuedMessages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
streamIsLoading={stream.isLoading}
|
streamIsLoading={stream.isLoading}
|
||||||
isThinking={isThinking}
|
isThinking={isThinking}
|
||||||
|
|
@ -129,7 +177,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
contentWidthClass="max-w-3xl"
|
contentWidthClass="max-w-3xl"
|
||||||
/>
|
/>
|
||||||
<div className="shrink-0 px-4 pb-4">
|
<div className="shrink-0 px-4 pb-4">
|
||||||
<div className="mx-auto w-full min-w-0 max-w-3xl">
|
<div className="mx-auto w-full max-w-3xl min-w-0">
|
||||||
<AgentPromptBar
|
<AgentPromptBar
|
||||||
placeholder="Add a follow up"
|
placeholder="Add a follow up"
|
||||||
compact
|
compact
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,45 @@ import { useLiveMarkdownMessageId } from "@/lib/agents/provider/useLiveMarkdownM
|
||||||
|
|
||||||
const BOTTOM_LOCK_THRESHOLD_PX = 24;
|
const BOTTOM_LOCK_THRESHOLD_PX = 24;
|
||||||
|
|
||||||
export const Messages = memo(function Messages({
|
function QueuedMessages({
|
||||||
|
queuedMessages,
|
||||||
|
}: {
|
||||||
|
queuedMessages: NonNullable<MessagesProps["queuedMessages"]>;
|
||||||
|
}) {
|
||||||
|
if (queuedMessages.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 space-y-2" data-testid="queued-messages">
|
||||||
|
{queuedMessages.map((message, index) => {
|
||||||
|
const imageCount = message.images?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className="ml-auto max-w-[85%] rounded-2xl border border-dashed border-[var(--ui-border)] bg-[var(--ui-panel)] px-3 py-2 text-[13px] text-[color:var(--ui-text)] shadow-sm"
|
||||||
|
data-testid="queued-message"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-[color:var(--ui-text-dim)]">
|
||||||
|
<span>
|
||||||
|
{queuedMessages.length > 1 ? `Queued next #${index + 1}` : "Queued next"}
|
||||||
|
</span>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ui-accent)]" />
|
||||||
|
</div>
|
||||||
|
{message.content && <div className="whitespace-pre-wrap break-words">{message.content}</div>}
|
||||||
|
{imageCount > 0 && (
|
||||||
|
<div className="mt-1 text-xs text-[color:var(--ui-text-muted)]">
|
||||||
|
{imageCount} image{imageCount === 1 ? "" : "s"} attached
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Messages = memo(function MessagesComponent({
|
||||||
messages,
|
messages,
|
||||||
|
queuedMessages = [],
|
||||||
isStreaming,
|
isStreaming,
|
||||||
streamIsLoading,
|
streamIsLoading,
|
||||||
isThinking,
|
isThinking,
|
||||||
|
|
@ -212,6 +249,7 @@ export const Messages = memo(function Messages({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<QueuedMessages queuedMessages={queuedMessages} />
|
||||||
<ThinkingSpinner
|
<ThinkingSpinner
|
||||||
isActive={isThinking ?? streamIsLoading ?? isStreaming}
|
isActive={isThinking ?? streamIsLoading ?? isStreaming}
|
||||||
settingUpSandbox={settingUpSandbox}
|
settingUpSandbox={settingUpSandbox}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Message, Project } from "@/lib/agents/types";
|
import type { Message, Project, QueuedThreadMessage } from "@/lib/agents/types";
|
||||||
|
|
||||||
export interface ChangedFileSummaryItem {
|
export interface ChangedFileSummaryItem {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -21,6 +21,7 @@ export type MessagesScrollControl = {
|
||||||
|
|
||||||
export interface MessagesProps extends ApprovalCallbacks {
|
export interface MessagesProps extends ApprovalCallbacks {
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
|
queuedMessages?: Array<QueuedThreadMessage>;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
/** Live run signal from `useStream().isLoading` — drives Streamdown token animation. */
|
/** Live run signal from `useStream().isLoading` — drives Streamdown token animation. */
|
||||||
streamIsLoading?: boolean;
|
streamIsLoading?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react";
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||||
|
|
||||||
import type { SendAgentMessageVariables } from "@/lib/agents/queries";
|
import type { SendAgentMessageVariables } from "@/lib/agents/queries"
|
||||||
import { AgentsApiError, agentsApi } from "@/lib/agents/api";
|
import type { AgentThread } from "@/lib/agents/types"
|
||||||
import { agentThreadKeys, invalidateAgentThreadLists } from "@/lib/agents/queries";
|
import { AgentsApiError, agentsApi } from "@/lib/agents/api"
|
||||||
|
import {
|
||||||
|
agentThreadKeys,
|
||||||
|
invalidateAgentThreadLists,
|
||||||
|
} from "@/lib/agents/queries"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct the message content for the LangGraph run.
|
* Construct the message content for the LangGraph run.
|
||||||
|
|
@ -12,14 +16,44 @@ import { agentThreadKeys, invalidateAgentThreadLists } from "@/lib/agents/querie
|
||||||
* @returns The message content.
|
* @returns The message content.
|
||||||
*/
|
*/
|
||||||
function messageContent(vars: SendAgentMessageVariables) {
|
function messageContent(vars: SendAgentMessageVariables) {
|
||||||
const text = vars.content.trim();
|
const text = vars.content.trim()
|
||||||
const imageBlocks = vars.images?.map((image) => ({
|
const imageBlocks =
|
||||||
type: "image",
|
vars.images?.map((image) => ({
|
||||||
base64: image.base64,
|
type: "image",
|
||||||
mime_type: image.mimeType,
|
base64: image.base64,
|
||||||
...(image.fileName ? { file_name: image.fileName } : {}),
|
mime_type: image.mimeType,
|
||||||
})) ?? [];
|
...(image.fileName ? { file_name: image.fileName } : {}),
|
||||||
return [...imageBlocks, ...(text ? [{ type: "text", text }] : [])];
|
})) ?? []
|
||||||
|
return [...imageBlocks, ...(text ? [{ type: "text", text }] : [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQueuedMessage(
|
||||||
|
thread: AgentThread,
|
||||||
|
vars: SendAgentMessageVariables,
|
||||||
|
id: string,
|
||||||
|
createdAt: number
|
||||||
|
): AgentThread {
|
||||||
|
return {
|
||||||
|
...thread,
|
||||||
|
queuedMessages: [
|
||||||
|
...(thread.queuedMessages ?? []),
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
content: vars.content.trim(),
|
||||||
|
images: vars.images,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQueuedMessage(thread: AgentThread, id: string): AgentThread {
|
||||||
|
return {
|
||||||
|
...thread,
|
||||||
|
queuedMessages: thread.queuedMessages?.filter(
|
||||||
|
(message) => message.id !== id
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,49 +66,65 @@ function messageContent(vars: SendAgentMessageVariables) {
|
||||||
* That endpoint writes to the thread store; `check_message_queue_before_model`
|
* That endpoint writes to the thread store; `check_message_queue_before_model`
|
||||||
* injects the message into the *current* run before the next model call — the
|
* injects the message into the *current* run before the next model call — the
|
||||||
* same mid-run follow-up path used by Slack, Linear, and GitHub webhooks.
|
* same mid-run follow-up path used by Slack, Linear, and GitHub webhooks.
|
||||||
*
|
*
|
||||||
* @param threadId - The ID of the thread to submit the message to.
|
* @param threadId - The ID of the thread to submit the message to.
|
||||||
* @returns The mutation object.
|
* @returns The mutation object.
|
||||||
*/
|
*/
|
||||||
export function useSubmitAgentMessage(threadId: string) {
|
export function useSubmitAgentMessage(threadId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const stream = useAgentThreadStream();
|
const stream = useAgentThreadStream()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (vars: SendAgentMessageVariables) => {
|
mutationFn: async (vars: SendAgentMessageVariables) => {
|
||||||
const queue = () =>
|
const queue = async () => {
|
||||||
agentsApi.queueMessage(threadId, {
|
const queuedAt = Date.now()
|
||||||
content: vars.content,
|
const queuedId = `queued-${queuedAt}-${Math.random().toString(36).slice(2)}`
|
||||||
images: vars.images,
|
queryClient.setQueryData<AgentThread>(
|
||||||
model_id: vars.model_id,
|
agentThreadKeys.detail(threadId),
|
||||||
effort: vars.effort,
|
(prev) =>
|
||||||
plan_mode: vars.plan_mode,
|
prev ? appendQueuedMessage(prev, vars, queuedId, queuedAt) : prev
|
||||||
});
|
)
|
||||||
|
try {
|
||||||
if (stream.isLoading) {
|
await agentsApi.queueMessage(threadId, {
|
||||||
await queue();
|
content: vars.content,
|
||||||
return;
|
images: vars.images,
|
||||||
}
|
model_id: vars.model_id,
|
||||||
|
effort: vars.effort,
|
||||||
try {
|
plan_mode: vars.plan_mode,
|
||||||
await queue();
|
})
|
||||||
return;
|
} catch (error) {
|
||||||
} catch (error) {
|
queryClient.setQueryData<AgentThread>(
|
||||||
if (!(error instanceof AgentsApiError) || error.status !== 409) {
|
agentThreadKeys.detail(threadId),
|
||||||
throw error;
|
(prev) => (prev ? removeQueuedMessage(prev, queuedId) : prev)
|
||||||
|
)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const configurable: Record<string, unknown> = {};
|
if (stream.isLoading) {
|
||||||
|
await queue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queue()
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof AgentsApiError) || error.status !== 409) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configurable: Record<string, unknown> = {}
|
||||||
if (vars.model_id && vars.effort) {
|
if (vars.model_id && vars.effort) {
|
||||||
configurable.agent_model_id = vars.model_id;
|
configurable.agent_model_id = vars.model_id
|
||||||
configurable.agent_effort = vars.effort;
|
configurable.agent_effort = vars.effort
|
||||||
}
|
}
|
||||||
if (vars.plan_mode) {
|
if (vars.plan_mode) {
|
||||||
configurable.plan_mode = true;
|
configurable.plan_mode = true
|
||||||
}
|
}
|
||||||
const config =
|
const config =
|
||||||
Object.keys(configurable).length > 0 ? { configurable } : undefined;
|
Object.keys(configurable).length > 0 ? { configurable } : undefined
|
||||||
|
|
||||||
// Don't await: `stream.submit` resolves only when the run *finishes*, so
|
// Don't await: `stream.submit` resolves only when the run *finishes*, so
|
||||||
// awaiting would keep the mutation `isPending` (and the prompt bar
|
// awaiting would keep the mutation `isPending` (and the prompt bar
|
||||||
|
|
@ -83,7 +133,7 @@ export function useSubmitAgentMessage(threadId: string) {
|
||||||
void stream
|
void stream
|
||||||
.submit(
|
.submit(
|
||||||
{ messages: [{ type: "human", content: messageContent(vars) }] },
|
{ messages: [{ type: "human", content: messageContent(vars) }] },
|
||||||
{ config },
|
{ config }
|
||||||
)
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// The run failed to start (e.g. expired OAuth token → 401, or a
|
// The run failed to start (e.g. expired OAuth token → 401, or a
|
||||||
|
|
@ -91,16 +141,16 @@ export function useSubmitAgentMessage(threadId: string) {
|
||||||
// `status: "running"`. Surface the failure and clear the busy state
|
// `status: "running"`. Surface the failure and clear the busy state
|
||||||
// instead of leaving the thread falsely running.
|
// instead of leaving the thread falsely running.
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
||||||
prev ? { ...prev, status: "error" as const } : prev,
|
prev ? { ...prev, status: "error" as const } : prev
|
||||||
);
|
)
|
||||||
invalidateAgentThreadLists(queryClient);
|
invalidateAgentThreadLists(queryClient)
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
||||||
prev ? { ...prev, status: "running" as const } : prev,
|
prev ? { ...prev, status: "running" as const } : prev
|
||||||
);
|
)
|
||||||
invalidateAgentThreadLists(queryClient);
|
invalidateAgentThreadLists(queryClient)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,13 @@ export interface AgentSchedule {
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueuedThreadMessage {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
images?: Array<ImageChunk>
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentThread {
|
export interface AgentThread {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -196,6 +203,7 @@ export interface AgentThread {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
traceUrl?: string | null
|
traceUrl?: string | null
|
||||||
messages: Array<Message>
|
messages: Array<Message>
|
||||||
|
queuedMessages?: Array<QueuedThreadMessage>
|
||||||
pr?: {
|
pr?: {
|
||||||
number: number
|
number: number
|
||||||
title: string
|
title: string
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue