Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions alembic/versions/d8d9404cdee2_add_agent_session_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""add_agent_session_status

Revision ID: d8d9404cdee2
Revises: c7737fa6338a
Create Date: 2026-01-21 17:40:55.526398

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "d8d9404cdee2"
down_revision: str | None = "c7737fa6338a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"agent_session",
sa.Column(
"status", sa.String(length=20), server_default="idle", nullable=False
),
)
op.create_index(
op.f("ix_agent_session_status"), "agent_session", ["status"], unique=False
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_agent_session_status"), table_name="agent_session")
op.drop_column("agent_session", "status")
# ### end Alembic commands ###
31 changes: 31 additions & 0 deletions frontend/src/client/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,11 @@ export const $AgentOutput = {
format: "uuid",
title: "Session Id",
},
interrupted: {
type: "boolean",
title: "Interrupted",
default: false,
},
},
type: "object",
required: ["output", "duration", "session_id"],
Expand Down Expand Up @@ -1675,6 +1680,10 @@ export const $AgentSessionRead = {
],
title: "Harness Type",
},
status: {
$ref: "#/components/schemas/AgentSessionStatus",
default: "idle",
},
last_stream_id: {
anyOf: [
{
Expand Down Expand Up @@ -1801,6 +1810,10 @@ export const $AgentSessionReadVercel = {
],
title: "Harness Type",
},
status: {
$ref: "#/components/schemas/AgentSessionStatus",
default: "idle",
},
last_stream_id: {
anyOf: [
{
Expand Down Expand Up @@ -1935,6 +1948,10 @@ export const $AgentSessionReadWithMessages = {
],
title: "Harness Type",
},
status: {
$ref: "#/components/schemas/AgentSessionStatus",
default: "idle",
},
last_stream_id: {
anyOf: [
{
Expand Down Expand Up @@ -1993,6 +2010,20 @@ export const $AgentSessionReadWithMessages = {
description: "Response schema for agent session with message history.",
} as const

export const $AgentSessionStatus = {
type: "string",
enum: ["idle", "running", "interrupted", "completed", "failed"],
title: "AgentSessionStatus",
description: `Status of an agent session.

Tracks the lifecycle state of an agent session:
- IDLE: No active workflow running
- RUNNING: Workflow currently executing
- INTERRUPTED: User requested interrupt (transient state)
- COMPLETED: Last run completed successfully
- FAILED: Last run failed`,
} as const

export const $AgentSessionUpdate = {
properties: {
title: {
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/client/services.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ import type {
AgentSessionsGetSessionResponse,
AgentSessionsGetSessionVercelData,
AgentSessionsGetSessionVercelResponse,
AgentSessionsInterruptSessionData,
AgentSessionsInterruptSessionResponse,
AgentSessionsListSessionsData,
AgentSessionsListSessionsResponse,
AgentSessionsSendMessageData,
Expand Down Expand Up @@ -3543,6 +3545,41 @@ export const agentSessionsForkSession = (
})
}

/**
* Interrupt Session
* Request interruption of a running agent session.
*
* Marks the session for interrupt. The agent executor will detect this
* status change and terminate execution cleanly, emitting stream.done()
* to prevent the frontend from hanging.
*
* Returns:
* {"interrupted": true} if the session was running and is now interrupted,
* {"interrupted": false} if the session was not in a running state.
* @param data The data for the request.
* @param data.sessionId
* @param data.workspaceId
* @returns boolean Successful Response
* @throws ApiError
*/
export const agentSessionsInterruptSession = (
data: AgentSessionsInterruptSessionData
): CancelablePromise<AgentSessionsInterruptSessionResponse> => {
return __request(OpenAPI, {
method: "POST",
url: "/agent/sessions/{session_id}/interrupt",
path: {
session_id: data.sessionId,
},
query: {
workspace_id: data.workspaceId,
},
errors: {
422: "Validation Error",
},
})
}

/**
* Submit Approvals
* Submit approval decisions to a running agent workflow.
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export type AgentOutput = {
duration: number
usage?: RunUsage | null
session_id: string
interrupted?: boolean
}

export type AgentPreset = {
Expand Down Expand Up @@ -392,6 +393,7 @@ export type AgentSessionRead = {
tools: Array<string> | null
agent_preset_id: string | null
harness_type: string | null
status?: AgentSessionStatus
last_stream_id?: string | null
parent_session_id?: string | null
created_at: string
Expand All @@ -411,6 +413,7 @@ export type AgentSessionReadVercel = {
tools: Array<string> | null
agent_preset_id: string | null
harness_type: string | null
status?: AgentSessionStatus
last_stream_id?: string | null
parent_session_id?: string | null
created_at: string
Expand All @@ -434,6 +437,7 @@ export type AgentSessionReadWithMessages = {
tools: Array<string> | null
agent_preset_id: string | null
harness_type: string | null
status?: AgentSessionStatus
last_stream_id?: string | null
parent_session_id?: string | null
created_at: string
Expand All @@ -444,6 +448,23 @@ export type AgentSessionReadWithMessages = {
messages?: Array<unknown>
}

/**
* Status of an agent session.
*
* Tracks the lifecycle state of an agent session:
* - IDLE: No active workflow running
* - RUNNING: Workflow currently executing
* - INTERRUPTED: User requested interrupt (transient state)
* - COMPLETED: Last run completed successfully
* - FAILED: Last run failed
*/
export type AgentSessionStatus =
| "idle"
| "running"
| "interrupted"
| "completed"
| "failed"

/**
* Request schema for updating an agent session.
*/
Expand Down Expand Up @@ -6802,6 +6823,15 @@ export type AgentSessionsForkSessionData = {

export type AgentSessionsForkSessionResponse = AgentSessionRead

export type AgentSessionsInterruptSessionData = {
sessionId: string
workspaceId: string
}

export type AgentSessionsInterruptSessionResponse = {
[key: string]: boolean
}

export type ApprovalsSubmitApprovalsData = {
requestBody: ApprovalSubmission
sessionId: string
Expand Down Expand Up @@ -9598,6 +9628,23 @@ export type $OpenApiTs = {
}
}
}
"/agent/sessions/{session_id}/interrupt": {
post: {
req: AgentSessionsInterruptSessionData
res: {
/**
* Successful Response
*/
200: {
[key: string]: boolean
}
/**
* Validation Error
*/
422: HTTPValidationError
}
}
}
"/approvals/{session_id}": {
post: {
req: ApprovalsSubmitApprovalsData
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/ai-elements/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ export const PromptInputActionMenuItem = ({

export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus
onInterrupt?: () => void
}

export const PromptInputSubmit = ({
Expand All @@ -686,6 +687,7 @@ export const PromptInputSubmit = ({
size = "icon",
status,
children,
onInterrupt,
...props
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-3.5" />
Expand All @@ -698,17 +700,22 @@ export const PromptInputSubmit = ({
Icon = <XIcon className="size-3.5" />
}

// When streaming, the button becomes an interrupt/stop button
const isStreaming = status === "streaming"
const handleClick = isStreaming && onInterrupt ? onInterrupt : undefined

return (
<Button
aria-label="Send message"
aria-label={isStreaming ? "Stop generation" : "Send message"}
className={cn("size-6 rounded-md hover:text-muted-foreground", className)}
size={size}
type="submit"
type={isStreaming ? "button" : "submit"}
variant={variant}
onClick={handleClick}
{...props}
>
{children ?? Icon}
<span className="sr-only">Send message</span>
<span className="sr-only">{isStreaming ? "Stop" : "Send message"}</span>
</Button>
)
}
Expand Down
25 changes: 17 additions & 8 deletions frontend/src/components/chat/chat-session-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,21 @@ export function ChatSessionPane({
() => (chat?.messages || []).map(toUIMessage),
[chat?.messages]
)
const { sendMessage, messages, status, regenerate, lastError, clearError } =
useVercelChat({
chatId: chat.id,
workspaceId,
messages: uiMessages,
modelInfo,
})

const {
sendMessage,
messages,
status,
regenerate,
lastError,
clearError,
interrupt,
} = useVercelChat({
chatId: chat.id,
workspaceId,
messages: uiMessages,
modelInfo,
})

// Track whether we've sent the pending message to avoid double-sends
const pendingMessageSentRef = useRef(false)
Expand Down Expand Up @@ -451,8 +459,9 @@ export function ChatSessionPane({
</PromptInputTools>
)}
<PromptInputSubmit
disabled={isReadonly || !input || !!status}
disabled={isReadonly || (!input && !status)}
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The updated disabled condition enables the button during non-streaming in-flight states (e.g., status="submitted"), so it becomes a submit button while a response is pending. This can trigger overlapping sendMessage calls and out-of-order responses. Keep the button disabled for non-streaming statuses while still allowing interrupts during streaming.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/components/chat/chat-session-pane.tsx, line 462:

<comment>The updated disabled condition enables the button during non-streaming in-flight states (e.g., status="submitted"), so it becomes a submit button while a response is pending. This can trigger overlapping sendMessage calls and out-of-order responses. Keep the button disabled for non-streaming statuses while still allowing interrupts during streaming.</comment>

<file context>
@@ -451,8 +459,9 @@ export function ChatSessionPane({
             )}
             <PromptInputSubmit
-              disabled={isReadonly || !input || !!status}
+              disabled={isReadonly || (!input && !status)}
               status={status}
+              onInterrupt={interrupt}
</file context>
Suggested change
disabled={isReadonly || (!input && !status)}
disabled={isReadonly || (status && status !== "streaming") || (!input && !status)}
Fix with Cubic

status={status}
onInterrupt={interrupt}
className="ml-auto text-muted-foreground/80"
/>
</PromptInputToolbar>
Expand Down
23 changes: 16 additions & 7 deletions frontend/src/components/copilot/copilot-chat-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,21 @@ export function CopilotChatPane({
() => (chat?.messages || []).map(toUIMessage),
[chat?.messages]
)
const { sendMessage, messages, status, regenerate, lastError, clearError } =
useVercelChat({
chatId: chat.id,
workspaceId,
messages: uiMessages,
modelInfo,
})

const {
sendMessage,
messages,
status,
regenerate,
lastError,
clearError,
interrupt,
} = useVercelChat({
chatId: chat.id,
workspaceId,
messages: uiMessages,
modelInfo,
})

const isWaitingForResponse = useMemo(() => {
if (status === "submitted") return true
Expand Down Expand Up @@ -248,6 +256,7 @@ export function CopilotChatPane({
<PromptInputSubmit
disabled={isReadonly || (!input && !status)}
status={status}
onInterrupt={interrupt}
className="ml-auto text-muted-foreground/80"
/>
</PromptInputToolbar>
Expand Down
Loading
Loading