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
55 changes: 55 additions & 0 deletions examples/playground/components/playground/AlphaFeaturesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import {
LayoutList,
BarChart2,
ChevronRight,
Layers,
KeyRound,
} from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
Expand Down Expand Up @@ -225,6 +228,58 @@ export function AlphaFeaturesSection({

<div className="h-px bg-zinc-100 dark:bg-zinc-800 my-2" />

{/* Threads */}
<p className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide mb-1 flex items-center gap-1">
<Layers className="w-3 h-3" /> Threads
</p>
<FeatureRow
id="alpha-concurrent-threads"
icon={Layers}
label="Concurrent Threads"
description="Each thread streams independently — switch away mid-response"
checked={alphaConfig.concurrentThreads}
onCheckedChange={(v) => onUpdate("concurrentThreads", v)}
/>

<div className="h-px bg-zinc-100 dark:bg-zinc-800 my-2" />

{/* YourGPT Auth */}
<p className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide mb-1 flex items-center gap-1">
<KeyRound className="w-3 h-3" /> YourGPT Auth
</p>
<FeatureRow
id="alpha-ygpt-auth"
icon={KeyRound}
label="Use yourgptConfig"
description="Creates sessions via YourGPT createSession API (overrides onCreateSession)"
checked={alphaConfig.yourgptAuthEnabled}
onCheckedChange={(v) => onUpdate("yourgptAuthEnabled", v)}
/>
{alphaConfig.yourgptAuthEnabled && (
<div className="space-y-1.5 pl-8 pr-1 pb-1">
<Label className="text-[10px] text-zinc-500">API Key</Label>
<Input
type="password"
value={alphaConfig.yourgptApiKey}
onChange={(e) => onUpdate("yourgptApiKey", e.target.value)}
placeholder="ygpt_..."
className="h-7 text-xs"
/>
<Label className="text-[10px] text-zinc-500">Widget UID</Label>
<Input
value={alphaConfig.yourgptWidgetUid}
onChange={(e) => onUpdate("yourgptWidgetUid", e.target.value)}
placeholder="wgt_..."
className="h-7 text-xs"
/>
<p className="text-[9px] text-zinc-400">
Stored in localStorage. Reload chat after saving.
</p>
</div>
)}

<div className="h-px bg-zinc-100 dark:bg-zinc-800 my-2" />

{/* Tools */}
<p className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide mb-1 flex items-center gap-1">
<Zap className="w-3 h-3" /> Advanced Tools
Expand Down
13 changes: 12 additions & 1 deletion examples/playground/components/playground/CopilotSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,23 @@ export function CopilotSidebar({
<div className="flex-1 min-h-0 h-full rounded-2xl overflow-hidden shadow-[0_0_10px_0_rgba(0,0,0,0.05)] border">
<CopilotProvider
debug={true}
key={`${selectedProvider}-${selectedOpenRouterModel}`} // Force re-mount when provider or model changes
key={`${selectedProvider}-${selectedOpenRouterModel}-${alphaConfig.concurrentThreads ? "ct" : "st"}-${alphaConfig.yourgptAuthEnabled ? "ygpt" : "local"}`} // Force re-mount when provider, model, thread mode, or auth mode changes
runtimeUrl={runtimeUrl}
headers={runtimeHeaders}
systemPrompt={systemPrompt}
maxIterations={5}
onError={handleError}
{...(alphaConfig.yourgptAuthEnabled &&
alphaConfig.yourgptApiKey &&
alphaConfig.yourgptWidgetUid
? {
yourgptConfig: {
apiKey: alphaConfig.yourgptApiKey,
widgetUid: alphaConfig.yourgptWidgetUid,
},
}
: {})}
concurrentThreads={alphaConfig.concurrentThreads}
messageHistory={
alphaConfig.compactionStrategy !== "none"
? { strategy: alphaConfig.compactionStrategy }
Expand Down
4 changes: 4 additions & 0 deletions examples/playground/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ export const INITIAL_ALPHA_CONFIG: AlphaConfig = {
compactionStrategy: "none",
sessionPersistence: false,
contextStats: false,
concurrentThreads: false,
yourgptAuthEnabled: false,
yourgptApiKey: "",
yourgptWidgetUid: "",
hiddenAnalytics: false,
deferredSearch: false,
customMessageView: false,
Expand Down
6 changes: 6 additions & 0 deletions examples/playground/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ export interface AlphaConfig {
compactionStrategy: CompactionStrategy;
sessionPersistence: boolean;
contextStats: boolean;
// Threads
concurrentThreads: boolean;
// YourGPT session auth (replaces onCreateSession callback on the provider)
yourgptAuthEnabled: boolean;
yourgptApiKey: string;
yourgptWidgetUid: string;
// Tools
hiddenAnalytics: boolean;
deferredSearch: boolean;
Expand Down
4 changes: 2 additions & 2 deletions examples/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"@radix-ui/react-switch": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/typography": "^0.5.19",
"@yourgpt/copilot-sdk": "^2.1.8",
"@yourgpt/llm-sdk": "^2.1.8",
"@yourgpt/copilot-sdk": "workspace:*",
"@yourgpt/llm-sdk": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
Expand Down
5 changes: 4 additions & 1 deletion packages/copilot-sdk/src/chat/AbstractAgentLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,12 @@ export class AbstractAgentLoop implements AgentLoopActions {
throw new Error("Tool execution cancelled");
}

// Pass signal and approvalData to handler via context
// Pass signal, threadId, and approvalData to handler via context.
// threadId lets handlers scope per-run state (e.g. per-thread tab
// pinning when multiple threads stream concurrently).
const result = await tool.handler(toolCall.args, {
signal: this.abortController?.signal,
threadId: this.config.getThreadId?.(),
data: { toolCallId: toolCall.id },
approvalData,
});
Expand Down
16 changes: 16 additions & 0 deletions packages/copilot-sdk/src/chat/ChatWithTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export interface ChatWithToolsConfig {
transport?: ChatTransport;
/** Custom error message extractor for non-2xx API responses */
parseError?: (status: number, body: unknown) => string | null | undefined;
/**
* Override the thread id exposed to tool handlers (`context.threadId`).
* Called once per tool invocation. Defaults to `chat.threadId` (the backend
* session id). Useful for framework adapters that maintain a stable
* UI-level thread id separate from the backend session id — e.g. when a
* local id is assigned client-side before the server issues its own.
*/
getThreadId?: () => string | undefined;
}

/**
Expand Down Expand Up @@ -128,6 +136,14 @@ export class ChatWithTools {
{
maxIterations: config.maxIterations ?? 20,
tools: config.tools,
// Expose this chat's current threadId to tool handlers via
// ToolContext.threadId. Read lazily per invocation so it reflects
// the id assigned by the server mid-stream (thread:created).
// If the caller provided a getThreadId override (e.g. the React
// provider exposing its stable registry key), prefer that.
getThreadId: config.getThreadId
? () => config.getThreadId!()
: () => this.chat?.threadId,
},
{
onExecutionsChange: (executions) => {
Expand Down
67 changes: 54 additions & 13 deletions packages/copilot-sdk/src/chat/classes/AbstractChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ export class AbstractChat<T extends UIMessage = UIMessage> {
return this.transport.isStreaming();
}

/** The thread id currently associated with this chat, if any. */
get threadId(): string | undefined {
return this.config.threadId;
}

// ============================================
// Public Actions
// ============================================
Expand Down Expand Up @@ -1087,6 +1092,10 @@ export class AbstractChat<T extends UIMessage = UIMessage> {

let chunkCount = 0;
let toolCallsEmitted = false; // Guard to prevent emitting toolCalls twice
// Set to true when post-tool text was already streamed live via auto-init
// from a message:delta (no message:start). Prevents done handler from
// inserting a duplicate when seenToolResult is true.
let postToolTextStreamed = false;
// Holds client tool calls received via a tool_calls chunk AFTER a
// mid-stream message:end nulled streamState.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -1188,10 +1197,30 @@ export class AbstractChat<T extends UIMessage = UIMessage> {

// Update stream state (pure function)
// Skip most chunks if streamState is null.
// EXCEPTION: after a mid-stream message:end the server can still send
// EXCEPTION 1: after a mid-stream message:end the server can still send
// tool_calls + done for client-side tool dispatch. Handle those directly.
// EXCEPTION 2: some server-side tools (e.g. websearch) stream the post-tool
// response via message:delta WITHOUT a preceding message:start. Auto-init a
// new message so the text streams live instead of appearing all-at-once from done.
if (!this.streamState) {
if (chunk.type === "tool_calls") {
if (chunk.type === "message:delta") {
this.debug(
"message:delta with no streamState — auto-init new message",
);
const currentLeaf = this.state.messages;
const currentLeafId =
currentLeaf.length > 0
? currentLeaf[currentLeaf.length - 1].id
: undefined;
const newMessage = createEmptyAssistantMessage(undefined, {
parentId: currentLeafId,
}) as T;
this.state.pushMessage(newMessage);
this.streamState = createStreamState(newMessage.id);
this.callbacks.onMessageStart?.(newMessage.id);
postToolTextStreamed = true;
// No continue — fall out of this block into normal processStreamChunk below
} else if (chunk.type === "tool_calls") {
// Store for emission when done arrives. Do NOT update message state
// here — done.messages carries the assistant message with tool_calls
// in proper OpenAI format, which we use in the done handler below.
Expand All @@ -1202,9 +1231,7 @@ export class AbstractChat<T extends UIMessage = UIMessage> {
ids: pendingClientToolCalls?.map((tc: { id?: string }) => tc.id),
});
continue;
}

if (chunk.type === "done") {
} else if (chunk.type === "done") {
this.debug("done (post-message:end)", {
hasPendingToolCalls: !!pendingClientToolCalls?.length,
pendingCount: pendingClientToolCalls?.length ?? 0,
Expand Down Expand Up @@ -1340,10 +1367,12 @@ export class AbstractChat<T extends UIMessage = UIMessage> {
});
}
continue;
} else {
this.debug("warning", "streamState is null, skipping chunk");
continue;
}

this.debug("warning", "streamState is null, skipping chunk");
continue;
// Only message:delta reaches here — streamState was just auto-init'd.
// Fall through to processStreamChunk below.
}
this.streamState = processStreamChunk(chunk, this.streamState);

Expand Down Expand Up @@ -1474,13 +1503,25 @@ export class AbstractChat<T extends UIMessage = UIMessage> {
}
}

// Track whether a tool-result message has been seen as we iterate done.messages.
// Assistant text that appears AFTER a tool result was never streamed live
// (server-side builtin tools like websearch/webanswer return the full history
// only in the done payload). Assistant text that appears BEFORE any tool result
// is the intro turn already represented by streamed message:start/delta/end events.
let seenToolResult = false;
for (const msg of chunk.messages) {
// Skip plain assistant text messages because they are already represented
// by streamed message:start/message:delta/message:end events. Preserve
// assistant messages that carry tool_calls so tool results keep a valid
// preceding assistant tool_call message in local state.
if (msg.role === "tool") seenToolResult = true;

// Skip plain assistant text messages that precede any tool result — those
// are already represented by streamed message:start/message:delta/message:end
// events. Assistant messages that appear AFTER a tool result are post-tool
// responses that were never streamed live and must be inserted — UNLESS
// postToolTextStreamed is true, meaning message:delta already delivered them
// live via the auto-init path (skipping avoids a duplicate message).
if (msg.role === "assistant" && !msg.tool_calls?.length) {
continue;
if (!seenToolResult) continue;
if (postToolTextStreamed) continue;
// Post-tool text: fall through to insert.
}

// The current streamed turn already becomes an assistant message from
Expand Down
7 changes: 7 additions & 0 deletions packages/copilot-sdk/src/chat/types/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ export interface AgentLoopConfig {
tools?: ToolDefinition[];
/** Max tool executions to keep in memory (default: 100). Oldest are pruned. */
maxExecutionHistory?: number;
/**
* Returns the current thread id for tool handler context. Called once per
* tool invocation. Needed by consumers that need to scope per-tool state
* (e.g. per-thread browser-tab pinning in the Chrome extension). When
* omitted, `context.threadId` is undefined in handlers.
*/
getThreadId?: () => string | undefined;
}

/**
Expand Down
Loading