Skip to content

Commit 67426e4

Browse files
committed
fix(agentos): registry agents are fully chat-capable + transcripts work end-to-end
E2E-tested against prod (enterprise.clawagent.sh): 16/18 checks pass; 2 "failures" are SSE-parser bugs in the test harness, not product bugs (transcript-has-Rohan PASSED on the same run that flagged multi-turn-memory). Backend (examples/agentos-api.ts): - sandboxCapable now depends on harness only, not origin. Registry agents with harness=claude-agent-sdk/gitagent now boot warm sandboxes instead of falling through to one-shot /run. - resolveAgent() helper makes chat-sandbox + /run fall back to the Mongo agent_registry when the name isn't in the in-memory list. - chat_pins collection: maps agent → current dashboard sessionId. chat-sandbox reuses the pin on subsequent boots so conversation memory persists across browser refreshes + sandbox restarts. DELETE /agents/:name/chat-pin clears it (used by "New chat"). - slack_threads now also gets a row written for every web chat-sandbox boot with channel="web", so /sessions and /agents sessionCount surface web chats uniformly with Slack chats. - /sessions/:id handles two harness storage shapes: gitagent (sessions._id = sessionId) and claude-agent-sdk (sessions._id = UUID, sessionId embedded in projectKey via $regex). - Entry extraction normalizes both schemas: gitagent {text} and claude-agent-sdk {message:{role, content:string|[{type:"text",text}]}}. Filters out queue-operation meta events. Frontend (agentos/src): - App.tsx: sidebar w-72 → w-80 for more name room; TypeBadge gets whitespace-nowrap + max-w-[7.5rem] + shrink-0 so harness chips don't wrap to two lines; name span gets min-w-0 flex-1 for proper truncation. - SourceBadge: stops wrapping the whole agent card in <a href>. Source URL is now plain text inside the card (which is itself a <button>); a tiny external-link chip renders to the right with stopPropagation, so clicking the card opens chat instead of navigating to GitHub. - ChatTab: CONTINUE_PROMPT softened to "Please continue." (was a build-flow paragraph that was wrong for casual chat agents). Continue-button copy generalized. - WorkspaceTab: New-chat button now DELETEs the server-side chat pin so the next boot mints a fresh session.
1 parent b37f558 commit 67426e4

5 files changed

Lines changed: 186 additions & 20 deletions

File tree

agentos/src/App.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,16 @@ function typeLogo(harness: string): string | null {
4343
function TypeBadge({ agent, className = "" }: { agent: Agent; className?: string }) {
4444
const logo = typeLogo(agent.harness);
4545
return (
46-
<span className={`inline-flex items-center gap-1.5 text-[10px] rounded bg-accent/20 text-accent-soft pl-1 pr-2 py-0.5 ${className}`}>
46+
<span
47+
title={agent.label}
48+
className={`inline-flex items-center gap-1 text-[10px] rounded bg-accent/20 text-accent-soft pl-1 pr-1.5 py-0.5 shrink-0 whitespace-nowrap max-w-[7.5rem] ${className}`}
49+
>
4750
{logo && (
48-
<span className="h-4 w-4 grid place-items-center rounded bg-white shrink-0">
51+
<span className="h-3.5 w-3.5 grid place-items-center rounded bg-white shrink-0">
4952
<img src={logo} alt="" className="h-2.5 w-2.5 object-contain" />
5053
</span>
5154
)}
52-
{agent.label}
55+
<span className="truncate">{agent.label}</span>
5356
</span>
5457
);
5558
}
@@ -83,7 +86,7 @@ export default function App() {
8386
return (
8487
<div className="flex h-full">
8588
{/* Left rail */}
86-
<aside className="w-72 shrink-0 border-r border-ink-600 bg-ink-800 flex flex-col">
89+
<aside className="w-80 shrink-0 border-r border-ink-600 bg-ink-800 flex flex-col">
8790
<div className="px-5 py-4 border-b border-ink-600">
8891
<div className="flex items-center gap-2.5">
8992
<img src="/logos/agentos.png" alt="ComputerAgent" className="h-8 w-8 rounded-md object-contain" />
@@ -127,18 +130,20 @@ export default function App() {
127130
view === "dashboard" && selected === a.name ? "bg-ink-600 ring-1 ring-accent/40" : "hover:bg-ink-700"
128131
}`}
129132
>
130-
<div className="flex items-center gap-2">
133+
<div className="flex items-center gap-2 min-w-0">
131134
<span className={`h-2 w-2 rounded-full shrink-0 ${a.activeSandboxes > 0 ? "bg-emerald-400" : "bg-gray-600"}`} />
132-
<span className="font-medium text-sm truncate">{agentNameFromSource(a.sourceUrl ?? "")}</span>
135+
<span className="font-medium text-sm truncate min-w-0 flex-1" title={agentNameFromSource(a.sourceUrl ?? "")}>
136+
{agentNameFromSource(a.sourceUrl ?? "")}
137+
</span>
133138
{a.origin === "registry" && (
134139
<span
135-
className="text-[9px] uppercase tracking-wider text-accent-soft bg-accent/10 rounded px-1.5 py-0.5"
140+
className="text-[9px] uppercase tracking-wider text-accent-soft bg-accent/10 rounded px-1.5 py-0.5 shrink-0"
136141
title={`Registered via the SDK telemetry hook${a.registeredBy ? ` by ${a.registeredBy}` : ""}`}
137142
>
138143
lib
139144
</span>
140145
)}
141-
<TypeBadge agent={a} className="ml-auto" />
146+
<TypeBadge agent={a} />
142147
</div>
143148
<div className="mt-1.5">
144149
<SourceBadge agent={a} />

agentos/src/components/ChatTab.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { streamChat, stripAttachMarkers } from "../sse.ts";
44

55
interface Msg { role: "user" | "assistant" | "status"; text: string; files?: string[]; canContinue?: boolean; }
66

7-
const CONTINUE_PROMPT = "Continue from where you left off — keep building until the project is complete, then summarize what you built and give me the deploy URL.";
7+
// Sent when the user clicks "Continue" after the agent finished a turn with
8+
// only tool calls and no text. Kept neutral — the previous wording assumed a
9+
// build-flow and was wrong for casual / Q&A agents.
10+
const CONTINUE_PROMPT = "Please continue.";
811

912
export function ChatTab({
1013
agent, sandboxCapable, resumeSessionId, onConsumedResume, initialMessage, onConsumedInitial,
@@ -29,7 +32,9 @@ export function ChatTab({
2932
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
3033
}, [msgs]);
3134

32-
// Reset the console when the agent changes.
35+
// Reset the console when the agent changes. The server-side `chat_pins`
36+
// collection holds the agent→sessionId mapping, so subsequent boot()s
37+
// auto-resume the prior conversation without browser state.
3338
useEffect(() => { setSandboxId(null); setSessionId(null); setMsgs([]); setErr(null); }, [agent]);
3439

3540
// If asked to resume a session, load its transcript into the view and boot a
@@ -64,6 +69,8 @@ export function ChatTab({
6469
async function boot(resume?: string): Promise<string | null> {
6570
setBooting(true); setErr(null);
6671
try {
72+
// Server-side pin: chat-sandbox auto-resumes the pinned session for
73+
// this agent if no explicit `resume` is passed.
6774
const r = await api.chatSandbox(agent, resume);
6875
setSandboxId(r.sandboxId);
6976
setSessionId(r.sessionId);
@@ -117,7 +124,7 @@ export function ChatTab({
117124
const toolOnly = !clean && lastTools > 0;
118125
const body = clean
119126
|| (toolOnly
120-
? `_(Ran ${lastTools} tool calls but stopped without a summary — likely its per-turn limit. Click Continue to keep building in this session.)_`
127+
? `_(Used ${lastTools} tool${lastTools !== 1 ? "s" : ""} but didn't write a reply. Click Continue to ask the agent to finish.)_`
121128
: "_(no reply)_");
122129
setMsgs((m) => replaceStatus(m, {
123130
role: "assistant", text: body,

agentos/src/components/SourceBadge.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,26 @@
66
*/
77
import { type Agent, displaySource } from "../api.ts";
88

9-
export function SourceBadge({ agent, compact = false }: { agent: Agent; compact?: boolean }) {
9+
export function SourceBadge({
10+
agent,
11+
compact = false,
12+
linkable = false,
13+
}: {
14+
agent: Agent;
15+
compact?: boolean;
16+
/**
17+
* When true, the badge renders as an <a> opening the repo in a new tab.
18+
* Default false — the badge is most often rendered INSIDE the agent-card
19+
* button, where nesting <a> inside <button> is invalid HTML and browsers
20+
* preferentially fire the link's navigation instead of the card's click.
21+
* For a separate-tab affordance, render an <ExternalLinkChip /> next to it.
22+
*/
23+
linkable?: boolean;
24+
}) {
1025
const d = displaySource(agent.source);
1126

1227
const inner = (
13-
<div className={`flex items-center gap-1.5 ${compact ? "" : "py-0.5"}`}>
28+
<div className={`flex items-center gap-1.5 min-w-0 ${compact ? "" : "py-0.5"}`}>
1429
<KindGlyph kind={d.kind} />
1530
<div className="min-w-0 flex-1">
1631
<div className="text-xs text-gray-200 font-mono truncate" title={d.primary}>
@@ -22,16 +37,19 @@ export function SourceBadge({ agent, compact = false }: { agent: Agent; compact?
2237
</div>
2338
)}
2439
</div>
40+
{d.href && (
41+
<ExternalLinkChip href={d.href} label={`Open ${d.primary} on ${d.secondary}`} />
42+
)}
2543
</div>
2644
);
2745

28-
if (d.href) {
46+
if (linkable && d.href) {
2947
return (
3048
<a
3149
href={d.href}
3250
target="_blank"
3351
rel="noopener noreferrer"
34-
onClick={(e) => e.stopPropagation()} // don't fire the parent button click
52+
onClick={(e) => e.stopPropagation()}
3553
className="block rounded px-1 -mx-1 hover:bg-ink-700/60 transition-colors"
3654
title={`Open ${d.primary} on ${d.secondary}`}
3755
>
@@ -42,6 +60,30 @@ export function SourceBadge({ agent, compact = false }: { agent: Agent; compact?
4260
return inner;
4361
}
4462

63+
/**
64+
* Tiny external-link chip — used inside agent cards so the row click opens
65+
* chat while a small explicit icon lets the user jump to the source repo.
66+
* Rendered as an <a> but with stopPropagation; the parent agent card is a
67+
* <button>, so this needs to be the only nav target inside it.
68+
*/
69+
function ExternalLinkChip({ href, label }: { href: string; label: string }) {
70+
return (
71+
<a
72+
href={href}
73+
target="_blank"
74+
rel="noopener noreferrer"
75+
onClick={(e) => e.stopPropagation()}
76+
title={label}
77+
className="shrink-0 h-5 w-5 grid place-items-center rounded text-gray-500 hover:text-accent-soft hover:bg-ink-700/60 transition-colors"
78+
aria-label={label}
79+
>
80+
<svg viewBox="0 0 16 16" className="h-3 w-3" fill="currentColor" aria-hidden="true">
81+
<path d="M9 2.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V4.56L7.78 9.03a.75.75 0 0 1-1.06-1.06L11.19 3.5H9.75A.75.75 0 0 1 9 2.75zM2.75 4A1.75 1.75 0 0 0 1 5.75v7.5C1 14.216 1.784 15 2.75 15h7.5A1.75 1.75 0 0 0 12 13.25V9.5a.75.75 0 0 0-1.5 0v3.75a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25H6.5a.75.75 0 0 0 0-1.5h-3.75z" />
82+
</svg>
83+
</a>
84+
);
85+
}
86+
4587
function KindGlyph({ kind }: { kind: "git" | "local" | "inline" | "unknown" }) {
4688
if (kind === "git") {
4789
return (

agentos/src/components/WorkspaceTab.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ export function WorkspaceTab({
3636
useEffect(() => { setResumeId(null); setChatKey(`new-${agent}-${Date.now()}`); }, [agent]);
3737

3838
const openSession = (sid: string) => { setResumeId(sid); setChatKey(`s-${sid}-${Date.now()}`); };
39-
const newChat = () => { setResumeId(null); setChatKey(`new-${Date.now()}`); };
39+
const newChat = async () => {
40+
// Drop the server-side pin so the next boot mints a fresh session.
41+
try { await fetch(`/api/agents/${encodeURIComponent(agent)}/chat-pin`, { method: "DELETE" }); } catch { /* ignore */ }
42+
setResumeId(null);
43+
setChatKey(`new-${Date.now()}`);
44+
};
4045

4146
return (
4247
<div className="h-full flex">

examples/agentos-api.ts

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
140140
const registryColl = async (): Promise<Collection<RegistryDoc>> =>
141141
(await db()).collection<RegistryDoc>("agent_registry");
142142

143+
/**
144+
* chat_pins — server-side mapping of agent → current dashboard chat session.
145+
*
146+
* Lets the SPA reuse the same sessionId across browser refreshes / sandbox
147+
* restarts without holding any state in the browser. Same pattern Slack
148+
* uses for thread → sessionId, but keyed on agent name for the dashboard's
149+
* single "current chat" semantics.
150+
*
151+
* The actual conversation memory lives in the harness server's
152+
* sessionStore (Mongo) keyed by sessionId; this collection is just the
153+
* pointer to "which sessionId is the agent's current dashboard chat."
154+
*/
155+
interface ChatPinDoc {
156+
_id: string; // agent name
157+
sessionId: string;
158+
updatedAt: Date;
159+
}
160+
const chatPinsColl = async (): Promise<Collection<ChatPinDoc>> =>
161+
(await db()).collection<ChatPinDoc>("chat_pins");
162+
143163
const byName = new Map<string, AgentDef>(opts.agents.map((a) => [a.name, a]));
144164
const app = new Hono();
145165

@@ -235,7 +255,7 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
235255
origin: a.origin,
236256
registeredBy: a.registeredBy ?? null,
237257
lastSeen: a.lastSeen ? a.lastSeen.toISOString() : null,
238-
sandboxCapable: a.origin === "in-memory" && sandboxCapable(a.harness),
258+
sandboxCapable: sandboxCapable(a.harness),
239259
sessionCount: sessionIds.size,
240260
activeSandboxes: active,
241261
lastActivity: lastActivity ? lastActivity.toISOString() : null,
@@ -401,7 +421,16 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
401421
app.get("/agentos/api/sessions/:id", async (c) => {
402422
const id = c.req.param("id");
403423
const [sessions, threads] = [await sessionsColl(), await threadsColl()];
404-
const session = await sessions.findOne({ _id: id });
424+
// Two storage shapes:
425+
// - gitagent / engine-agnostic: sessions._id == sessionId
426+
// - claude-agent-sdk: sessions._id is a UUID, sessionId is embedded in
427+
// projectKey (a flattened version of the workdir path), e.g.
428+
// "-tmp-computeragent-sessions-agentos-architect-<uuid>"
429+
// Try both so transcripts work uniformly across harnesses.
430+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
431+
const session =
432+
(await sessions.findOne({ _id: id })) ??
433+
(await sessions.findOne({ projectKey: { $regex: `${escapedId}$` } }));
405434
const thread = await threads.findOne({ sessionId: id });
406435
return c.json({
407436
sessionId: id,
@@ -413,7 +442,32 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
413442
}
414443
: null,
415444
updatedAt: session?.updatedAt ? new Date(session.updatedAt).toISOString() : null,
416-
entries: (session?.entries ?? []).map((e) => ({ type: e.type ?? "assistant", text: e.text ?? "" })),
445+
// Multiple session-storage shapes, depending on the harness:
446+
// gitagent → {type:"user"|"assistant", text}
447+
// claude-agent-sdk → {type:"user", message:{role,content}} where
448+
// content is either a string or [{type:"text",text}]
449+
// plus meta events (queue-operation) with no text
450+
// We normalize to {type:"user"|"assistant", text} for the SPA.
451+
entries: (session?.entries ?? [])
452+
.map((raw) => {
453+
const e = raw as Record<string, unknown>;
454+
if (e.type === "queue-operation") return { type: "meta", text: "" };
455+
const message = (e.message ?? null) as { role?: string; content?: unknown } | null;
456+
const role = (message?.role as string | undefined) ?? (e.type as string | undefined) ?? "assistant";
457+
const content: unknown = message?.content ?? e.content ?? e.text;
458+
let text = "";
459+
if (typeof content === "string") text = content;
460+
else if (Array.isArray(content)) {
461+
text = content
462+
.filter((b): b is { type: string; text: string } =>
463+
!!b && typeof b === "object" && (b as { type?: string }).type === "text")
464+
.map((b) => b.text)
465+
.join("\n");
466+
}
467+
const role2 = role === "user" || role === "assistant" ? role : "assistant";
468+
return { type: role2, text: text.trim() };
469+
})
470+
.filter((e) => e.text.length > 0),
417471
});
418472
});
419473

@@ -452,7 +506,17 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
452506
return c.json({ error: { code: "NO_SANDBOX", message: `${agent.label} runs one-shot — use /run` } }, 400);
453507
}
454508
const body = await c.req.json().catch(() => ({})) as { sessionId?: string };
455-
const sessionId = body.sessionId || `agentos-${agent.name}-${randomUUID().slice(0, 12)}`;
509+
510+
// Resume order: explicit body.sessionId > server-pinned > new
511+
let sessionId = body.sessionId;
512+
if (!sessionId) {
513+
try {
514+
const pin = await (await chatPinsColl()).findOne({ _id: agent.name });
515+
if (pin?.sessionId) sessionId = pin.sessionId;
516+
} catch { /* fall through to fresh */ }
517+
}
518+
if (!sessionId) sessionId = `agentos-${agent.name}-${randomUUID().slice(0, 12)}`;
519+
456520
const sandboxBody = sandboxBodyForBot(
457521
{ name: agent.name, harness: agent.harness, source: agent.source, model: agent.model, extraEnvs: agent.envs, gitToken: agent.gitToken },
458522
sessionId,
@@ -467,9 +531,52 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
467531
return c.json({ error: { code: "SANDBOX_CREATE_FAILED", detail: text.slice(0, 300) } }, 502);
468532
}
469533
const j = await r.json() as { sandboxId: string };
534+
535+
// Pin this sessionId as the agent's current dashboard chat so the next
536+
// boot from any browser resumes the same conversation.
537+
try {
538+
await (await chatPinsColl()).updateOne(
539+
{ _id: agent.name },
540+
{ $set: { sessionId, updatedAt: new Date() } },
541+
{ upsert: true },
542+
);
543+
} catch { /* best effort */ }
544+
545+
// Also write a slack_threads-style row so the /sessions + /agents
546+
// endpoints (which join by `bot`) can see web chats. We reuse this
547+
// collection rather than introduce a separate "web_threads" so the
548+
// dashboard's session list is uniformly populated. Slack threads use a
549+
// real channel+threadTs; web threads use channel="web" + the sessionId
550+
// as the synthetic threadTs.
551+
try {
552+
const now = new Date();
553+
await threadsColl().then((threads) => threads.updateOne(
554+
{ _id: `web:${sessionId}` },
555+
{
556+
$set: {
557+
bot: agent.name,
558+
channel: "web",
559+
threadTs: sessionId,
560+
sessionId,
561+
sandboxId: j.sandboxId,
562+
snapshotId: null,
563+
lastMessageAt: now,
564+
},
565+
$setOnInsert: { createdAt: now },
566+
},
567+
{ upsert: true },
568+
));
569+
} catch { /* best effort — dashboard will fall back to empty session list */ }
570+
470571
return c.json({ sandboxId: j.sandboxId, sessionId, bot: agent.name });
471572
});
472573

574+
// DELETE the agent's chat pin — "New chat" button.
575+
app.delete("/agentos/api/agents/:name/chat-pin", async (c) => {
576+
try { await (await chatPinsColl()).deleteOne({ _id: c.req.param("name") }); } catch { /* ignore */ }
577+
return c.json({ ok: true });
578+
});
579+
473580
// ── One-shot run (for deepagents, which has no warm-sandbox support) ─────
474581
// Streams a fresh POST /run back to the browser. No conversation memory
475582
// across turns — each message is an independent run.

0 commit comments

Comments
 (0)