Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4aeebafda | ||
|
|
1f6badb106 | ||
|
|
285f9efcf2 |
No files matched your search
+1
-2
@@ -19,8 +19,8 @@ Docs: https://docs.clawd.bot
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
|
||||
### Fixes
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
@@ -56,7 +56,6 @@ Docs: https://docs.clawd.bot
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ Implementation:
|
||||
|
||||
**OpenAI / OpenAI Codex**
|
||||
- Image sanitization only.
|
||||
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
|
||||
- No tool call id sanitization.
|
||||
- No tool result pairing repair.
|
||||
- No turn validation or reordering.
|
||||
|
||||
@@ -25,7 +25,6 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
|
||||
Notes:
|
||||
- Matching is case-insensitive.
|
||||
- `*` wildcards are supported (`"*"` means all tools).
|
||||
- If `tools.allow` only references unknown or unloaded plugin tool names, Clawdbot logs a warning and ignores the allowlist so core tools stay available.
|
||||
|
||||
## Tool profiles (base allowlist)
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
|
||||
|
||||
describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
it("keeps reasoning signatures when followed by content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
|
||||
it("drops orphaned reasoning blocks without following content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
|
||||
{ role: "user", content: "next" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops object-form orphaned signatures", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: { id: "rs_obj", type: "reasoning" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps non-reasoning thinking signatures", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "t",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
});
|
||||
@@ -31,8 +31,6 @@ export {
|
||||
parseImageDimensionError,
|
||||
} from "./pi-embedded-helpers/errors.js";
|
||||
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
|
||||
|
||||
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
|
||||
export {
|
||||
isEmptyAssistantMessageContent,
|
||||
sanitizeSessionMessagesImages,
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
type OpenAIThinkingBlock = {
|
||||
type?: unknown;
|
||||
thinking?: unknown;
|
||||
thinkingSignature?: unknown;
|
||||
};
|
||||
|
||||
type OpenAIReasoningSignature = {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
|
||||
if (!value) return null;
|
||||
let candidate: { id?: unknown; type?: unknown } | null = null;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||
try {
|
||||
candidate = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
candidate = value as { id?: unknown; type?: unknown };
|
||||
}
|
||||
if (!candidate) return null;
|
||||
const id = typeof candidate.id === "string" ? candidate.id : "";
|
||||
const type = typeof candidate.type === "string" ? candidate.type : "";
|
||||
if (!id.startsWith("rs_")) return null;
|
||||
if (type === "reasoning" || type.startsWith("reasoning.")) {
|
||||
return { id, type };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFollowingNonThinkingBlock(
|
||||
content: Extract<AgentMessage, { role: "assistant" }>["content"],
|
||||
index: number,
|
||||
): boolean {
|
||||
for (let i = index + 1; i < content.length; i++) {
|
||||
const block = content[i];
|
||||
if (!block || typeof block !== "object") return true;
|
||||
if ((block as { type?: unknown }).type !== "thinking") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
|
||||
* without the required following item.
|
||||
*
|
||||
* Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
|
||||
* is incomplete, drop the block to keep history usable.
|
||||
*/
|
||||
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||
const out: AgentMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = (msg as { role?: unknown }).role;
|
||||
if (role !== "assistant") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
if (!Array.isArray(assistantMsg.content)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
||||
|
||||
const nextContent: AssistantContentBlock[] = [];
|
||||
for (let i = 0; i < assistantMsg.content.length; i++) {
|
||||
const block = assistantMsg.content[i];
|
||||
if (!block || typeof block !== "object") {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
const record = block as OpenAIThinkingBlock;
|
||||
if (record.type !== "thinking") {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
const signature = parseOpenAIReasoningSignature(record.thinkingSignature);
|
||||
if (!signature) {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -161,92 +161,4 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("does not downgrade openai reasoning when the model has not changed", async () => {
|
||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||
{
|
||||
type: "custom",
|
||||
customType: "model-snapshot",
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.2-codex",
|
||||
},
|
||||
},
|
||||
];
|
||||
const sessionManager = {
|
||||
getEntries: vi.fn(() => sessionEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
sessionEntries.push({ type: "custom", customType, data });
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_test", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result).toEqual(messages);
|
||||
});
|
||||
|
||||
it("downgrades openai reasoning only when the model changes", async () => {
|
||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||
{
|
||||
type: "custom",
|
||||
customType: "model-snapshot",
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
provider: "anthropic",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "claude-3-7",
|
||||
},
|
||||
},
|
||||
];
|
||||
const sessionManager = {
|
||||
getEntries: vi.fn(() => sessionEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
sessionEntries.push({ type: "custom", customType, data });
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thinkingSignature: { id: "rs_test", type: "reasoning" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||
import {
|
||||
downgradeOpenAIReasoningBlocks,
|
||||
isCompactionFailureError,
|
||||
isGoogleModelApi,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
@@ -212,50 +211,7 @@ registerUnhandledRejectionHandler((reason) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
|
||||
|
||||
type ModelSnapshotEntry = {
|
||||
timestamp: number;
|
||||
provider?: string;
|
||||
modelApi?: string | null;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
|
||||
|
||||
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
|
||||
try {
|
||||
const entries = sessionManager.getEntries();
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i] as CustomEntryLike;
|
||||
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue;
|
||||
const data = entry?.data as ModelSnapshotEntry | undefined;
|
||||
if (data && typeof data === "object") {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
|
||||
try {
|
||||
sessionManager.appendCustomEntry(MODEL_SNAPSHOT_CUSTOM_TYPE, data);
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): boolean {
|
||||
const normalize = (value?: string | null) => value ?? "";
|
||||
return (
|
||||
normalize(a.provider) === normalize(b.provider) &&
|
||||
normalize(a.modelApi) === normalize(b.modelApi) &&
|
||||
normalize(a.modelId) === normalize(b.modelId)
|
||||
);
|
||||
}
|
||||
type CustomEntryLike = { type?: unknown; customType?: unknown };
|
||||
|
||||
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
|
||||
try {
|
||||
@@ -336,38 +292,12 @@ export async function sanitizeSessionHistory(params: {
|
||||
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||
: sanitizedThinking;
|
||||
|
||||
const isOpenAIResponsesApi =
|
||||
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
||||
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
||||
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
|
||||
const modelChanged = priorSnapshot
|
||||
? !isSameModelSnapshot(priorSnapshot, {
|
||||
timestamp: 0,
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
})
|
||||
: false;
|
||||
const sanitizedOpenAI =
|
||||
isOpenAIResponsesApi && modelChanged
|
||||
? downgradeOpenAIReasoningBlocks(repairedTools)
|
||||
: repairedTools;
|
||||
|
||||
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
||||
appendModelSnapshot(params.sessionManager, {
|
||||
timestamp: Date.now(),
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!policy.applyGoogleTurnOrdering) {
|
||||
return sanitizedOpenAI;
|
||||
return repairedTools;
|
||||
}
|
||||
|
||||
return applyGoogleTurnOrderingFix({
|
||||
messages: sanitizedOpenAI,
|
||||
messages: repairedTools,
|
||||
modelApi: params.modelApi,
|
||||
sessionManager: params.sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
|
||||
+30
-34
@@ -44,12 +44,10 @@ import {
|
||||
buildPluginToolGroups,
|
||||
collectExplicitAllowlist,
|
||||
expandPolicyWithPluginGroups,
|
||||
normalizeToolName,
|
||||
resolveToolProfilePolicy,
|
||||
stripPluginOnlyAllowlist,
|
||||
} from "./tool-policy.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
|
||||
function isOpenAIProvider(provider?: string) {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
@@ -255,6 +253,11 @@ export function createClawdbotCodingTools(options?: {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const bashTool = {
|
||||
...(execTool as unknown as AnyAgentTool),
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
} satisfies AnyAgentTool;
|
||||
const processTool = createProcessTool({
|
||||
cleanupMs: cleanupMsOverride ?? execConfig.cleanupMs,
|
||||
scopeKey,
|
||||
@@ -275,6 +278,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
: []),
|
||||
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
|
||||
execTool as unknown as AnyAgentTool,
|
||||
bashTool,
|
||||
processTool as unknown as AnyAgentTool,
|
||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||
...listChannelAgentTools({ cfg: options?.config }),
|
||||
@@ -315,46 +319,38 @@ export function createClawdbotCodingTools(options?: {
|
||||
modelHasVision: options?.modelHasVision,
|
||||
}),
|
||||
];
|
||||
const coreToolNames = new Set(
|
||||
tools
|
||||
.filter((tool) => !getPluginToolMeta(tool as AnyAgentTool))
|
||||
.map((tool) => normalizeToolName(tool.name))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const pluginGroups = buildPluginToolGroups({
|
||||
tools,
|
||||
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||
});
|
||||
const resolvePolicy = (policy: typeof profilePolicy, label: string) => {
|
||||
const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames);
|
||||
if (resolved.unknownAllowlist.length > 0) {
|
||||
const entries = resolved.unknownAllowlist.join(", ");
|
||||
const suffix = resolved.strippedAllowlist
|
||||
? "Ignoring allowlist so core tools remain available."
|
||||
: "These entries won't match any tool unless the plugin is enabled.";
|
||||
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
|
||||
}
|
||||
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
|
||||
};
|
||||
const profilePolicyExpanded = resolvePolicy(
|
||||
profilePolicy,
|
||||
profile ? `tools.profile (${profile})` : "tools.profile",
|
||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const providerProfileExpanded = resolvePolicy(
|
||||
providerProfilePolicy,
|
||||
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
|
||||
const providerProfileExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
|
||||
const globalProviderExpanded = resolvePolicy(globalProviderPolicy, "tools.byProvider.allow");
|
||||
const agentPolicyExpanded = resolvePolicy(
|
||||
agentPolicy,
|
||||
agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow",
|
||||
const globalPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const agentProviderExpanded = resolvePolicy(
|
||||
agentProviderPolicy,
|
||||
agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow",
|
||||
const globalProviderExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const agentPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const agentProviderExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const groupPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(groupPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const groupPolicyExpanded = resolvePolicy(groupPolicy, "group tools.allow");
|
||||
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
||||
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"title": "Exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Exec",
|
||||
"label": "exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
|
||||
@@ -6,46 +6,20 @@ const pluginGroups: PluginToolGroups = {
|
||||
all: ["lobster", "workflow_tool"],
|
||||
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
|
||||
};
|
||||
const coreTools = new Set(["read", "write", "exec", "session_status"]);
|
||||
|
||||
describe("stripPluginOnlyAllowlist", () => {
|
||||
it("strips allowlist when it only targets plugin tools", () => {
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups, coreTools);
|
||||
expect(policy.policy?.allow).toBeUndefined();
|
||||
expect(policy.unknownAllowlist).toEqual([]);
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
|
||||
expect(policy?.allow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips allowlist when it only targets plugin groups", () => {
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups, coreTools);
|
||||
expect(policy.policy?.allow).toBeUndefined();
|
||||
expect(policy.unknownAllowlist).toEqual([]);
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
|
||||
expect(policy?.allow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps allowlist when it mixes plugin and core entries", () => {
|
||||
const policy = stripPluginOnlyAllowlist(
|
||||
{ allow: ["lobster", "read"] },
|
||||
pluginGroups,
|
||||
coreTools,
|
||||
);
|
||||
expect(policy.policy?.allow).toEqual(["lobster", "read"]);
|
||||
expect(policy.unknownAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips allowlist with unknown entries when no core tools match", () => {
|
||||
const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() };
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, emptyPlugins, coreTools);
|
||||
expect(policy.policy?.allow).toBeUndefined();
|
||||
expect(policy.unknownAllowlist).toEqual(["lobster"]);
|
||||
});
|
||||
|
||||
it("keeps allowlist with core tools and reports unknown entries", () => {
|
||||
const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() };
|
||||
const policy = stripPluginOnlyAllowlist(
|
||||
{ allow: ["read", "lobster"] },
|
||||
emptyPlugins,
|
||||
coreTools,
|
||||
);
|
||||
expect(policy.policy?.allow).toEqual(["read", "lobster"]);
|
||||
expect(policy.unknownAllowlist).toEqual(["lobster"]);
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
|
||||
expect(policy?.allow).toEqual(["lobster", "read"]);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@ describe("tool-policy", () => {
|
||||
const expanded = expandToolGroups(["group:runtime", "BASH", "apply-patch", "group:fs"]);
|
||||
const set = new Set(expanded);
|
||||
expect(set.has("exec")).toBe(true);
|
||||
expect(set.has("bash")).toBe(true);
|
||||
expect(set.has("process")).toBe(true);
|
||||
expect(set.has("bash")).toBe(false);
|
||||
expect(set.has("apply_patch")).toBe(true);
|
||||
expect(set.has("read")).toBe(true);
|
||||
expect(set.has("write")).toBe(true);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
// Basic workspace/file tools
|
||||
"group:fs": ["read", "write", "edit", "apply_patch"],
|
||||
// Host/runtime execution tools
|
||||
"group:runtime": ["exec", "process"],
|
||||
"group:runtime": ["exec", "bash", "process"],
|
||||
// Session management tools
|
||||
"group:sessions": [
|
||||
"sessions_list",
|
||||
@@ -95,12 +95,6 @@ export type PluginToolGroups = {
|
||||
byPlugin: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export type AllowlistResolution = {
|
||||
policy: ToolPolicyLike | undefined;
|
||||
unknownAllowlist: string[];
|
||||
strippedAllowlist: boolean;
|
||||
};
|
||||
|
||||
export function expandToolGroups(list?: string[]) {
|
||||
const normalized = normalizeToolList(list);
|
||||
const expanded: string[] = [];
|
||||
@@ -187,33 +181,17 @@ export function expandPolicyWithPluginGroups(
|
||||
export function stripPluginOnlyAllowlist(
|
||||
policy: ToolPolicyLike | undefined,
|
||||
groups: PluginToolGroups,
|
||||
coreTools: Set<string>,
|
||||
): AllowlistResolution {
|
||||
if (!policy?.allow || policy.allow.length === 0) {
|
||||
return { policy, unknownAllowlist: [], strippedAllowlist: false };
|
||||
}
|
||||
): ToolPolicyLike | undefined {
|
||||
if (!policy?.allow || policy.allow.length === 0) return policy;
|
||||
const normalized = normalizeToolList(policy.allow);
|
||||
if (normalized.length === 0) {
|
||||
return { policy, unknownAllowlist: [], strippedAllowlist: false };
|
||||
}
|
||||
if (normalized.length === 0) return policy;
|
||||
const pluginIds = new Set(groups.byPlugin.keys());
|
||||
const pluginTools = new Set(groups.all);
|
||||
const unknownAllowlist: string[] = [];
|
||||
let hasCoreEntry = false;
|
||||
for (const entry of normalized) {
|
||||
const isPluginEntry =
|
||||
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
||||
const expanded = expandToolGroups([entry]);
|
||||
const isCoreEntry = expanded.some((tool) => coreTools.has(tool));
|
||||
if (isCoreEntry) hasCoreEntry = true;
|
||||
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
|
||||
}
|
||||
const strippedAllowlist = !hasCoreEntry;
|
||||
return {
|
||||
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
|
||||
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
|
||||
strippedAllowlist,
|
||||
};
|
||||
const isPluginEntry = (entry: string) =>
|
||||
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
||||
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
|
||||
if (!isPluginOnly) return policy;
|
||||
return { ...policy, allow: undefined };
|
||||
}
|
||||
|
||||
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||
|
||||
+2
-6
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -83,16 +82,13 @@ function stripWindowsNodeExec(argv: string[]): string[] {
|
||||
const execBase = path.basename(execPath).toLowerCase();
|
||||
const isExecPath = (value: string | undefined): boolean => {
|
||||
if (!value) return false;
|
||||
const normalized = normalizeCandidate(value);
|
||||
if (!normalized) return false;
|
||||
const lower = normalized.toLowerCase();
|
||||
const lower = normalizeCandidate(value).toLowerCase();
|
||||
return (
|
||||
lower === execPathLower ||
|
||||
path.basename(lower) === execBase ||
|
||||
lower.endsWith("\\node.exe") ||
|
||||
lower.endsWith("/node.exe") ||
|
||||
lower.includes("node.exe") ||
|
||||
(path.basename(lower) === "node.exe" && fs.existsSync(normalized))
|
||||
lower.includes("node.exe")
|
||||
);
|
||||
};
|
||||
const filtered = argv.filter((arg, index) => index === 0 || !isExecPath(arg));
|
||||
|
||||
@@ -125,43 +125,6 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("appends current time after the cron header line", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as {
|
||||
prompt?: string;
|
||||
};
|
||||
const lines = call?.prompt?.split("\n") ?? [];
|
||||
expect(lines[0]).toContain("[cron:job-1");
|
||||
expect(lines[0]).toContain("do it");
|
||||
expect(lines[1]).toMatch(/^Current time: .+ \(.+\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses agentId for workspace, session key, and store paths", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const deps: CliDeps = {
|
||||
|
||||
@@ -25,11 +25,6 @@ import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import { ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||
import {
|
||||
formatUserTime,
|
||||
resolveUserTimeFormat,
|
||||
resolveUserTimezone,
|
||||
} from "../../agents/date-time.js";
|
||||
import {
|
||||
formatXHighModelHint,
|
||||
normalizeThinkLevel,
|
||||
@@ -231,12 +226,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
});
|
||||
|
||||
const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
|
||||
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat);
|
||||
const formattedTime =
|
||||
formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString();
|
||||
const timeLine = `Current time: ${formattedTime} (${userTimezone})`;
|
||||
const commandBody = `${base}\n${timeLine}`.trim();
|
||||
const commandBody = base;
|
||||
|
||||
const existingSnapshot = cronSession.sessionEntry.skillsSnapshot;
|
||||
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createEventHandlers } from "./tui-event-handlers.js";
|
||||
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
type MockChatLog = {
|
||||
startTool: ReturnType<typeof vi.fn>;
|
||||
updateToolResult: ReturnType<typeof vi.fn>;
|
||||
addSystem: ReturnType<typeof vi.fn>;
|
||||
updateAssistant: ReturnType<typeof vi.fn>;
|
||||
finalizeAssistant: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe("tui-event-handlers: handleAgentEvent", () => {
|
||||
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
|
||||
agentDefaultId: "main",
|
||||
sessionMainKey: "agent:main:main",
|
||||
sessionScope: "global",
|
||||
agents: [],
|
||||
currentAgentId: "main",
|
||||
currentSessionKey: "agent:main:main",
|
||||
currentSessionId: "session-1",
|
||||
activeChatRunId: "run-1",
|
||||
historyLoaded: true,
|
||||
sessionInfo: {},
|
||||
initialSessionApplied: true,
|
||||
isConnected: true,
|
||||
autoMessageSent: false,
|
||||
toolsExpanded: false,
|
||||
showThinking: false,
|
||||
connectionStatus: "connected",
|
||||
activityStatus: "idle",
|
||||
statusTimeout: null,
|
||||
lastCtrlCAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeContext = (state: TuiStateAccess) => {
|
||||
const chatLog: MockChatLog = {
|
||||
startTool: vi.fn(),
|
||||
updateToolResult: vi.fn(),
|
||||
addSystem: vi.fn(),
|
||||
updateAssistant: vi.fn(),
|
||||
finalizeAssistant: vi.fn(),
|
||||
};
|
||||
const tui = { requestRender: vi.fn() };
|
||||
const setActivityStatus = vi.fn();
|
||||
|
||||
return { chatLog, tui, state, setActivityStatus };
|
||||
};
|
||||
|
||||
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
|
||||
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleAgentEvent } = createEventHandlers({
|
||||
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const evt: AgentEvent = {
|
||||
runId: "run-123",
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "start",
|
||||
toolCallId: "tc1",
|
||||
name: "exec",
|
||||
args: { command: "echo hi" },
|
||||
},
|
||||
};
|
||||
|
||||
handleAgentEvent(evt);
|
||||
|
||||
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", { command: "echo hi" });
|
||||
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores tool events when runId does not match activeChatRunId", () => {
|
||||
const state = makeState({ activeChatRunId: "run-1" });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const evt: AgentEvent = {
|
||||
runId: "run-2",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc1", name: "exec" },
|
||||
};
|
||||
|
||||
handleAgentEvent(evt);
|
||||
|
||||
expect(chatLog.startTool).not.toHaveBeenCalled();
|
||||
expect(chatLog.updateToolResult).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes lifecycle events when runId matches activeChatRunId", () => {
|
||||
const state = makeState({ activeChatRunId: "run-9" });
|
||||
const { tui, setActivityStatus } = makeContext(state);
|
||||
const { handleAgentEvent } = createEventHandlers({
|
||||
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const evt: AgentEvent = {
|
||||
runId: "run-9",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start" },
|
||||
};
|
||||
|
||||
handleAgentEvent(evt);
|
||||
|
||||
expect(setActivityStatus).toHaveBeenCalledWith("running");
|
||||
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures runId from chat events when activeChatRunId is unset", () => {
|
||||
const state = makeState({ activeChatRunId: null });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const chatEvt: ChatEvent = {
|
||||
runId: "run-42",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "delta",
|
||||
message: { content: "hello" },
|
||||
};
|
||||
|
||||
handleChatEvent(chatEvt);
|
||||
|
||||
expect(state.activeChatRunId).toBe("run-42");
|
||||
|
||||
const agentEvt: AgentEvent = {
|
||||
runId: "run-42",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc1", name: "exec" },
|
||||
};
|
||||
|
||||
handleAgentEvent(agentEvt);
|
||||
|
||||
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined);
|
||||
});
|
||||
|
||||
it("clears run mapping when the session changes", () => {
|
||||
const state = makeState({ activeChatRunId: null });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
handleChatEvent({
|
||||
runId: "run-old",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "delta",
|
||||
message: { content: "hello" },
|
||||
});
|
||||
|
||||
state.currentSessionKey = "agent:main:other";
|
||||
state.activeChatRunId = null;
|
||||
tui.requestRender.mockClear();
|
||||
|
||||
handleAgentEvent({
|
||||
runId: "run-old",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc2", name: "exec" },
|
||||
});
|
||||
|
||||
expect(chatLog.startTool).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores lifecycle updates for non-active runs in the same session", () => {
|
||||
const state = makeState({ activeChatRunId: "run-active" });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
handleChatEvent({
|
||||
runId: "run-other",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "delta",
|
||||
message: { content: "hello" },
|
||||
});
|
||||
setActivityStatus.mockClear();
|
||||
tui.requestRender.mockClear();
|
||||
|
||||
handleAgentEvent({
|
||||
runId: "run-other",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
expect(setActivityStatus).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,58 +15,33 @@ type EventHandlerContext = {
|
||||
export function createEventHandlers(context: EventHandlerContext) {
|
||||
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
const sessionRuns = new Map<string, number>();
|
||||
let streamAssembler = new TuiStreamAssembler();
|
||||
let lastSessionKey = state.currentSessionKey;
|
||||
|
||||
const pruneRunMap = (runs: Map<string, number>) => {
|
||||
if (runs.size <= 200) return;
|
||||
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, ts] of runs) {
|
||||
if (runs.size <= 150) break;
|
||||
if (ts < keepUntil) runs.delete(key);
|
||||
}
|
||||
if (runs.size > 200) {
|
||||
for (const key of runs.keys()) {
|
||||
runs.delete(key);
|
||||
if (runs.size <= 150) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncSessionKey = () => {
|
||||
if (state.currentSessionKey === lastSessionKey) return;
|
||||
lastSessionKey = state.currentSessionKey;
|
||||
finalizedRuns.clear();
|
||||
sessionRuns.clear();
|
||||
streamAssembler = new TuiStreamAssembler();
|
||||
};
|
||||
|
||||
const noteSessionRun = (runId: string) => {
|
||||
sessionRuns.set(runId, Date.now());
|
||||
pruneRunMap(sessionRuns);
|
||||
};
|
||||
const streamAssembler = new TuiStreamAssembler();
|
||||
|
||||
const noteFinalizedRun = (runId: string) => {
|
||||
finalizedRuns.set(runId, Date.now());
|
||||
sessionRuns.delete(runId);
|
||||
streamAssembler.drop(runId);
|
||||
pruneRunMap(finalizedRuns);
|
||||
if (finalizedRuns.size <= 200) return;
|
||||
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, ts] of finalizedRuns) {
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
if (ts < keepUntil) finalizedRuns.delete(key);
|
||||
}
|
||||
if (finalizedRuns.size > 200) {
|
||||
for (const key of finalizedRuns.keys()) {
|
||||
finalizedRuns.delete(key);
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as ChatEvent;
|
||||
syncSessionKey();
|
||||
if (evt.sessionKey !== state.currentSessionKey) return;
|
||||
if (finalizedRuns.has(evt.runId)) {
|
||||
if (evt.state === "delta") return;
|
||||
if (evt.state === "final") return;
|
||||
}
|
||||
noteSessionRun(evt.runId);
|
||||
if (!state.activeChatRunId) {
|
||||
state.activeChatRunId = evt.runId;
|
||||
}
|
||||
if (evt.state === "delta") {
|
||||
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
|
||||
if (!displayText) return;
|
||||
@@ -103,7 +78,6 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
if (evt.state === "aborted") {
|
||||
chatLog.addSystem("run aborted");
|
||||
streamAssembler.drop(evt.runId);
|
||||
sessionRuns.delete(evt.runId);
|
||||
state.activeChatRunId = null;
|
||||
setActivityStatus("aborted");
|
||||
void refreshSessionInfo?.();
|
||||
@@ -111,7 +85,6 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
if (evt.state === "error") {
|
||||
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
streamAssembler.drop(evt.runId);
|
||||
sessionRuns.delete(evt.runId);
|
||||
state.activeChatRunId = null;
|
||||
setActivityStatus("error");
|
||||
void refreshSessionInfo?.();
|
||||
@@ -122,11 +95,7 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
const handleAgentEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as AgentEvent;
|
||||
syncSessionKey();
|
||||
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the
|
||||
// active chat run id, not the session id.
|
||||
const isActiveRun = evt.runId === state.activeChatRunId;
|
||||
if (!isActiveRun && !sessionRuns.has(evt.runId)) return;
|
||||
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
|
||||
if (evt.stream === "tool") {
|
||||
const data = evt.data ?? {};
|
||||
const phase = asString(data.phase, "");
|
||||
@@ -148,7 +117,6 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
return;
|
||||
}
|
||||
if (evt.stream === "lifecycle") {
|
||||
if (!isActiveRun) return;
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start") setActivityStatus("running");
|
||||
if (phase === "end") setActivityStatus("idle");
|
||||
|
||||
Reference in new issue
Block a user