Skip to content
Open
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
14 changes: 14 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ export interface ToolStartedEvent {
args: string;
}

export interface ToolOutputEvent {
type: "tool_output";
agent_id: string;
channel_id: string | null;
process_type: string;
process_id: string;
/** Stable identifier matching the tool_call that initiated this stream. */
call_id: string;
tool_name: string;
line: string;
stream: "stdout" | "stderr";
}

export interface ToolCompletedEvent {
type: "tool_completed";
agent_id: string;
Expand Down Expand Up @@ -267,6 +280,7 @@ export type ApiEvent =
| BranchCompletedEvent
| ToolStartedEvent
| ToolCompletedEvent
| ToolOutputEvent
| OpenCodePartUpdatedEvent
| WorkerTextEvent
| CortexChatMessageEvent;
Expand Down
28 changes: 25 additions & 3 deletions interface/src/components/ToolCall.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import {useState} from "react";
import {cx} from "class-variance-authority";
import type {TranscriptStep, OpenCodePart} from "@/api/client";
import type {OpenCodePart} from "@/api/client";
import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types";

// Extended TranscriptStep with live_output for streaming shell output
type ExtendedTranscriptStep = SchemaTranscriptStep & {
live_output?: string;
};

// Use the extended type for pairing
type TranscriptStep = ExtendedTranscriptStep;

// ---------------------------------------------------------------------------
// Types
Expand All @@ -25,6 +34,8 @@ export interface ToolCallPair {
status: ToolCallStatus;
/** Human-readable summary provided by live opencode parts */
title?: string | null;
/** Live streaming output from tool_output SSE events (running tools only) */
liveOutput?: string;
}

// ---------------------------------------------------------------------------
Expand All @@ -42,12 +53,13 @@ export type TranscriptItem =

export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] {
const items: TranscriptItem[] = [];
const resultsById = new Map<string, {name: string; text: string}>();
const resultsById = new Map<string, {name: string; text: string; liveOutput?: string}>();

// First pass: index all tool_result steps by call_id
for (const step of steps) {
if (step.type === "tool_result") {
resultsById.set(step.call_id, {name: step.name, text: step.text});
const liveOutput = step.live_output;
resultsById.set(step.call_id, {name: step.name, text: step.text, liveOutput});
}
Comment on lines +56 to 63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't use placeholder tool_result steps as the completion signal.

interface/src/hooks/useLiveContext.tsx:239-275 now creates a temporary tool_result with text: "" just to carry live_output while the tool is still running. Line 91's result ? ... : "running" heuristic treats that placeholder as a real completion, so the card flips out of the running state on the first streamed line and the live-output branch below becomes effectively dead. It also leaves no way to represent the new waiting_for_input shell outcome distinctly from a hard error. Please carry an explicit pending/final status instead of deriving it from the presence of a tool_result record.

Also applies to: 82-93

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/ToolCall.tsx` around lines 56 - 63, The code
incorrectly treats any tool_result step as a final completion; update the
indexing in ToolCall.tsx (the resultsById Map created in the steps loop) to
include an explicit status field (e.g., status: "pending" | "final" |
"waiting_for_input" ) and the liveOutput separately instead of inferring
completion from the presence of tool_result. Change the producer in
useLiveContext.tsx to set that explicit status on the temporary tool_result it
emits while streaming, and then update the consumer logic (the conditional
around result ? ... : "running" and the live-output branch referenced around
lines ~82-93) to switch behavior based on the new status value (treat status ===
"pending" as running, status === "final" as completed, and handle
"waiting_for_input" distinctly).

}

Expand Down Expand Up @@ -77,6 +89,7 @@ export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] {
resultRaw: result?.text ?? null,
result: parsedResult,
status: result ? (isError ? "error" : "completed") : "running",
liveOutput: result?.liveOutput,
},
});
}
Expand Down Expand Up @@ -1118,6 +1131,15 @@ function renderResult(
renderer: ToolRenderer,
): React.ReactNode {
if (pair.status === "running") {
if (pair.liveOutput) {
return (
<div className="px-3 py-2">
<pre className="max-h-60 overflow-auto whitespace-pre-wrap font-mono text-tiny text-ink-dull">
{pair.liveOutput}
</pre>
</div>
);
}
return (
<div className="flex items-center gap-2 px-3 py-2 text-tiny text-ink-faint">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
Expand Down
49 changes: 47 additions & 2 deletions interface/src/hooks/useLiveContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { createContext, useContext, useCallback, useEffect, useRef, useState, useMemo, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type TranscriptStep, type OpenCodePart, type OpenCodePartUpdatedEvent, type WorkerTextEvent } from "@/api/client";
import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type ToolOutputEvent, type OpenCodePart, type OpenCodePartUpdatedEvent, type WorkerTextEvent } from "@/api/client";
import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types";

/** Extended TranscriptStep with live_output for streaming shell output */
type TranscriptStep = SchemaTranscriptStep & {
live_output?: string;
};
import { generateId } from "@/lib/id";
import { useEventSource, type ConnectionState } from "@/hooks/useEventSource";
import { useChannelLiveState, type ChannelLiveState, type ActiveWorker } from "@/hooks/useChannelLiveState";
Expand Down Expand Up @@ -230,6 +236,44 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re
}
}, [channelHandlers, bumpWorkerVersion]);

const handleToolOutput = useCallback((data: unknown) => {
const event = data as ToolOutputEvent;
if (event.process_type === "worker") {
setLiveTranscripts((prev) => {
const steps = prev[event.process_id] ?? [];
// Use the stable call_id from the event to find or create the result step
const existingIndex = steps.findIndex(
(s) => s.type === "tool_result" && s.call_id === event.call_id
);
if (existingIndex >= 0) {
// Append to existing result step with buffer size limit
const step = steps[existingIndex];
const existingOutput = (step as TranscriptStep).live_output ?? "";
const combined = existingOutput + event.line;
// Cap at ~50KB to prevent unbounded growth during long-running commands
const MAX_LIVE_OUTPUT_SIZE = 50000;
const newOutput = combined.length > MAX_LIVE_OUTPUT_SIZE
? combined.slice(-MAX_LIVE_OUTPUT_SIZE)
: combined;
const updatedStep = { ...step, live_output: newOutput };
const newSteps = [...steps];
newSteps[existingIndex] = updatedStep;
return { ...prev, [event.process_id]: newSteps };
}
// Create new result step with the event's call_id
const step: TranscriptStep = {
type: "tool_result",
call_id: event.call_id,
name: event.tool_name,
text: "",
live_output: event.line,
};
return { ...prev, [event.process_id]: [...steps, step] };
});
bumpWorkerVersion();
}
}, [bumpWorkerVersion]);

// Handle OpenCode part updates — upsert parts into the per-worker ordered map
const handleOpenCodePartUpdated = useCallback((data: unknown) => {
const event = data as OpenCodePartUpdatedEvent;
Expand Down Expand Up @@ -280,6 +324,7 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re
worker_completed: wrappedWorkerCompleted,
tool_started: wrappedToolStarted,
tool_completed: wrappedToolCompleted,
tool_output: handleToolOutput,
opencode_part_updated: handleOpenCodePartUpdated,
worker_text: handleWorkerText,
agent_message_sent: handleAgentMessage,
Expand All @@ -289,7 +334,7 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re
notification_created: handleNotificationCreated,
notification_updated: handleNotificationUpdated,
}),
[channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleOpenCodePartUpdated, handleWorkerText, handleAgentMessage, bumpTaskVersion, handleCortexChatMessage, handleNotificationCreated, handleNotificationUpdated],
[channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleToolOutput, handleOpenCodePartUpdated, handleWorkerText, handleAgentMessage, bumpTaskVersion, handleCortexChatMessage, handleNotificationCreated, handleNotificationUpdated],
);

const onReconnect = useCallback(() => {
Expand Down
187 changes: 187 additions & 0 deletions interface/src/lib/primitives.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import * as React from "react";
import {cx} from "class-variance-authority";
import {
Banner as PrimitiveBanner,
Button as PrimitiveButton,
FilterButton as PrimitiveFilterButton,
NumberStepper as PrimitiveNumberStepper,
} from "@spacedrive/primitives";

export * from "@spacedrive/primitives";

type LegacyButtonVariant = "ghost" | "secondary" | "destructive";
type PrimitiveButtonVariant = NonNullable<
React.ComponentProps<typeof PrimitiveButton>["variant"]
>;
type PrimitiveButtonSize = React.ComponentProps<typeof PrimitiveButton>["size"];
type PrimitiveButtonRounding =
React.ComponentProps<typeof PrimitiveButton>["rounding"];
type ButtonVariant = PrimitiveButtonVariant | LegacyButtonVariant;
type CompatButtonBaseProps = {
children?: React.ReactNode;
className?: string;
loading?: boolean;
rounding?: PrimitiveButtonRounding;
size?: PrimitiveButtonSize;
variant?: ButtonVariant;
};
type CompatActionButtonProps = CompatButtonBaseProps &
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
href?: undefined;
};
type CompatLinkButtonProps = CompatButtonBaseProps &
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & {
href: string;
};
type CompatButtonProps = CompatActionButtonProps | CompatLinkButtonProps;

function hasHref(
props: CompatButtonProps,
): props is CompatLinkButtonProps {
return "href" in props && props.href !== undefined;
}

function mapButtonVariant(
variant: ButtonVariant | undefined,
): PrimitiveButtonVariant | undefined {
switch (variant) {
case "ghost":
return "subtle";
case "secondary":
return "gray";
case "destructive":
return "outline";
default:
return variant;
}
}

function legacyButtonClassName(variant: ButtonVariant | undefined) {
if (variant === "destructive") {
return "border-red-500/40 text-red-300 hover:border-red-500/60 hover:bg-red-500/10";
}

return undefined;
}

function LoadingSpinner() {
return (
<span
aria-hidden="true"
className="inline-block size-3 shrink-0 animate-spin rounded-full border border-current border-t-transparent"
/>
);
}

export const Button = React.forwardRef<
React.ElementRef<typeof PrimitiveButton>,
CompatButtonProps
>(({variant, loading = false, className, children, ...props}, ref) => {
const buttonClassName = cx(legacyButtonClassName(variant), className);
const buttonVariant = mapButtonVariant(variant);
const content = (
<>
{loading ? <LoadingSpinner /> : null}
{children}
</>
);

if (hasHref(props)) {
return (
<PrimitiveButton
{...props}
ref={ref}
variant={buttonVariant}
className={buttonClassName}
>
{content}
</PrimitiveButton>
);
Comment on lines +89 to +99
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Apply the loading guard in the href branch too.

This branch renders the spinner, but unlike the action-button path it never marks the control busy/disabled or blocks activation. A loading link can still be focused and clicked, which makes the wrapper look disabled while remaining interactive.

Suggested patch
 	if (hasHref(props)) {
+		const {onClick, tabIndex, ...linkProps} = props;
 		return (
 			<PrimitiveButton
-				{...props}
+				{...linkProps}
 				ref={ref}
+				aria-busy={loading || undefined}
+				aria-disabled={loading || undefined}
+				tabIndex={loading ? -1 : tabIndex}
+				onClick={(event) => {
+					if (loading) {
+						event.preventDefault();
+						event.stopPropagation();
+						return;
+					}
+					onClick?.(event);
+				}}
 				variant={buttonVariant}
-				className={buttonClassName}
+				className={cx(buttonClassName, loading && "pointer-events-none")}
 			>
 				{content}
 			</PrimitiveButton>
 		);
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (hasHref(props)) {
return (
<PrimitiveButton
{...props}
ref={ref}
variant={buttonVariant}
className={buttonClassName}
>
{content}
</PrimitiveButton>
);
if (hasHref(props)) {
const {onClick, tabIndex, ...linkProps} = props;
return (
<PrimitiveButton
{...linkProps}
ref={ref}
aria-busy={loading || undefined}
aria-disabled={loading || undefined}
tabIndex={loading ? -1 : tabIndex}
onClick={(event) => {
if (loading) {
event.preventDefault();
event.stopPropagation();
return;
}
onClick?.(event);
}}
variant={buttonVariant}
className={cx(buttonClassName, loading && "pointer-events-none")}
>
{content}
</PrimitiveButton>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/lib/primitives.tsx` around lines 89 - 99, The href branch
rendering via hasHref(props) and PrimitiveButton must honor the loading guard
like the action-button path: when props.loading (or the component's loading
state) is true, set the element to disabled/aria-busy (prevent activation/clicks
and focus as appropriate), prevent navigation/activation handlers from running,
and render the spinner instead of interactive content; update the
PrimitiveButton usage in that branch (the JSX that spreads {...props} and passes
ref, variant/buttonVariant, className/buttonClassName, and children/content) to
include the same loading props/guards (disabled, aria-busy, and click/keypress
suppression) used by the action-button code path so the link appears and behaves
disabled while loading.

}

const actionProps = props as CompatActionButtonProps;

return (
<PrimitiveButton
{...actionProps}
ref={ref}
disabled={loading || actionProps.disabled}
aria-busy={loading || undefined}
variant={buttonVariant}
className={buttonClassName}
>
{content}
</PrimitiveButton>
);
});

Button.displayName = "Button";

type BannerDotMode = "pulse" | "static";
type CompatBannerProps = React.ComponentProps<typeof PrimitiveBanner> & {
dot?: BannerDotMode;
};

export const Banner = React.forwardRef<
React.ElementRef<typeof PrimitiveBanner>,
CompatBannerProps
>(({dot, showDot, className, ...props}, ref) => (
<PrimitiveBanner
{...props}
ref={ref}
showDot={showDot ?? dot !== undefined}
className={cx(
dot === "pulse" && "[&>span:first-child]:animate-pulse",
className,
)}
/>
));

Banner.displayName = "Banner";

type CompatFilterButtonProps = React.ComponentProps<
typeof PrimitiveFilterButton
> & {
colorClass?: string;
};

export const FilterButton = React.forwardRef<
React.ElementRef<typeof PrimitiveFilterButton>,
CompatFilterButtonProps
>(({colorClass, active, className, ...props}, ref) => (
<PrimitiveFilterButton
{...props}
ref={ref}
active={active}
className={cx(active && colorClass, className)}
/>
));

FilterButton.displayName = "FilterButton";

type CompatNumberStepperProps = React.ComponentProps<
typeof PrimitiveNumberStepper
> & {
type?: "float" | "int";
variant?: string;
};

export const NumberStepper = React.forwardRef<
React.ElementRef<typeof PrimitiveNumberStepper>,
CompatNumberStepperProps
>(({type, variant: _variant, allowFloat, ...props}, ref) => {
// Only pass allowFloat when explicitly provided or when type === "float"
// so PrimitiveNumberStepper can use its own default when neither is set.
const resolvedAllowFloat = allowFloat !== undefined
? allowFloat
: (type === "float" ? true : undefined);
return (
<PrimitiveNumberStepper
{...props}
ref={ref}
allowFloat={resolvedAllowFloat}
/>
);
});

NumberStepper.displayName = "NumberStepper";
4 changes: 3 additions & 1 deletion prompts/en/tools/shell_description.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ Execute a shell command. Use this for file operations, running scripts, building

Use the optional `env` parameter to set per-command environment variables (e.g. `[{"key": "RUST_LOG", "value": "debug"}]`). Dangerous variables that enable library injection (LD_PRELOAD, NODE_OPTIONS, etc.) are blocked.

To install tools that persist across restarts, place binaries in the persistent tools directory at $SPACEBOT_DIR/tools/bin (already on PATH). For example: `curl -fsSL https://example.com/tool -o $SPACEBOT_DIR/tools/bin/tool && chmod +x $SPACEBOT_DIR/tools/bin/tool`
To install tools that persist across restarts, place binaries in the persistent tools directory at $SPACEBOT_DIR/tools/bin (already on PATH). For example: `curl -fsSL https://example.com/tool -o $SPACEBOT_DIR/tools/bin/tool && chmod +x $SPACEBOT_DIR/tools/bin/tool`

Commands have no interactive stdin and no attached TTY — interactive prompts cannot be answered. If a command waits for input (no output for 5 seconds), it will be killed and you will receive `waiting_for_input: true` with the captured prompt text. To avoid this, always pass `--yes`, `-y`, or `--non-interactive` flags, use pipe input (e.g. `echo y | command`), or heredocs (`<<EOF`). Shell-created pipes and here-docs are valid non-interactive input methods. The environment already sets `CI=true` and `DEBIAN_FRONTEND=noninteractive`.
2 changes: 2 additions & 0 deletions prompts/en/worker.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ Examples:

Execute shell commands. Use this for running builds, tests, git operations, package management, and any system commands. Supports optional `env` parameter for setting per-command environment variables (e.g. `RUST_LOG=debug`).

Commands have no interactive stdin and no attached TTY. Interactive prompts (confirmations, license acceptances) will be detected and killed after 5 seconds of silence. Always use `--yes`, `-y`, `--non-interactive`, pipe input (e.g. `echo y | apt install foo`), or heredocs (`<<EOF`) to bypass prompts. Shell-created pipes and here-docs are valid non-interactive input methods. The environment already sets `CI=true` and `DEBIAN_FRONTEND=noninteractive`.

### File tools (file_read, file_write, file_edit, file_list)

Four separate tools for file operations:
Expand Down
3 changes: 2 additions & 1 deletion src/agent/channel_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@ pub(crate) fn event_is_for_channel(event: &ProcessEvent, channel_id: &ChannelId)
| ProcessEvent::StatusUpdate { .. }
| ProcessEvent::TaskUpdated { .. }
| ProcessEvent::WorkerText { .. }
| ProcessEvent::CortexChatUpdate { .. } => false,
| ProcessEvent::CortexChatUpdate { .. }
| ProcessEvent::ToolOutput { .. } => false,
}
}

Expand Down
Loading
Loading