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
6 changes: 5 additions & 1 deletion setup-podman.sh
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,11 @@ if ! run_as_openclaw test -f "$OPENCLAW_JSON"; then
fi

echo "Building image from $REPO_PATH..."
podman build -t openclaw:local -f "$REPO_PATH/Dockerfile" "$REPO_PATH"
podman build \
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES:-}" \
-t openclaw:local \
-f "$REPO_PATH/Dockerfile" \
"$REPO_PATH"

echo "Loading image into $OPENCLAW_USER's Podman store..."
TMP_IMAGE="$(mktemp -p /tmp openclaw-image.XXXXXX.tar)"
Expand Down
21 changes: 21 additions & 0 deletions src/agents/pi-embedded-block-chunker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,25 @@ describe("EmbeddedBlockChunker", () => {
expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]);
expect(chunker.bufferedText).toBe("After fence");
});

it("preserves leading paragraph separator when buffer starts with one (cross-turn separator)", () => {
const chunker = new EmbeddedBlockChunker({
minChars: 100,
maxChars: 200,
breakPreference: "paragraph",
flushOnParagraph: true,
});

// Simulate cross-turn separator added after reset (fixes #35344)
chunker.append("\n\n");
const chunks1 = drainChunks(chunker);
expect(chunks1).toEqual([]);
expect(chunker.bufferedText).toBe("\n\n");

// Next paragraph should include the separator
chunker.append("Next paragraph");
const chunks2 = drainChunks(chunker);
expect(chunks2).toEqual([]);
expect(chunker.bufferedText).toBe("\n\nNext paragraph");
});
});
7 changes: 7 additions & 0 deletions src/agents/pi-embedded-block-chunker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ export class EmbeddedBlockChunker {
if (chunk.trim().length > 0) {
emit(chunk);
}
// When the buffer starts with a paragraph break (index === 0), it's likely
// an intentional cross-turn separator. Keep it in the buffer so it gets
// prepended to the next paragraph (fixes #35344).
if (paragraphBreak.index === 0) {
// Buffer starts with separator; keep it and wait for more content
return;
}
this.#buffer = stripLeadingNewlines(
this.#buffer.slice(paragraphBreak.index + paragraphBreak.length),
);
Expand Down
17 changes: 16 additions & 1 deletion src/agents/pi-embedded-subscribe.handlers.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,23 @@ export function handleMessageStart(
// Start-of-message is a safer reset point than message_end: some providers
// may deliver late text_end updates after message_end, which would otherwise
// re-trigger block replies.

// Add cross-turn separator after resetting if this is not the first assistant message
// This prevents text concatenation when blockStreaming is enabled (fixes #35308)
const needsSeparator = ctx.state.deltaBuffer.trim();

ctx.resetAssistantMessageState(ctx.state.assistantTexts.length);
// Use assistant message_start as the earliest "writing" signal for typing.

if (needsSeparator) {
ctx.state.deltaBuffer = “\n\n”;
if (ctx.blockChunker) {
ctx.blockChunker.append(“\n\n”);
} else {
ctx.state.blockBuffer = “\n\n”;
}
}

// Use assistant message_start as the earliest “writing” signal for typing.
void ctx.params.onAssistantMessageStart?.();
}

Expand Down
82 changes: 82 additions & 0 deletions src/agents/skills/bundled-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { writeSkill } from "../skills.e2e-test-helpers.js";
import { resolveBundledSkillsContext } from "./bundled-context.js";

describe("resolveBundledSkillsContext", () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bundled-context-"));
// Set environment variable to override bundled skills directory
process.env.OPENCLAW_BUNDLED_SKILLS_DIR = tempDir;
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
delete process.env.OPENCLAW_BUNDLED_SKILLS_DIR;
});

it("handles numeric skill names without throwing TypeError", async () => {
// Note: The underlying @mariozechner/pi-coding-agent package also has the same bug
// in validateName() function. This test verifies that our fix in bundled-context.ts
// handles the case where skill.name might be a number type.

// For now, we use quoted YAML to ensure the skill loads successfully
// The real-world scenario where YAML parses unquoted numbers is handled by
// the String() coercion in bundled-context.ts
const skillDir = path.join(tempDir, "12306");
await fs.mkdir(skillDir, { recursive: true });

// Use quoted name to ensure it's parsed as string by YAML
const skillContent = `---
name: "12306"
description: Test skill with numeric name
---

# Test Skill

This skill has a numeric name.
`;
await fs.writeFile(path.join(skillDir, "SKILL.md"), skillContent, "utf-8");

const context = resolveBundledSkillsContext();

// The fix ensures that even if skill.name is a number, it's converted to string
expect(context.names.has("12306")).toBe(true);
expect(context.dir).toBe(tempDir);
});

it("handles string skill names normally", async () => {
await writeSkill({
dir: path.join(tempDir, "test-skill"),
name: "test-skill",
description: "Normal string skill name",
});

const context = resolveBundledSkillsContext();

expect(context.names.has("test-skill")).toBe(true);
});

it("filters out skills with empty names after trimming", async () => {
const skillDir = path.join(tempDir, "empty-name");
await fs.mkdir(skillDir, { recursive: true });

const skillContent = `---
name: " "
description: Skill with whitespace-only name
---

# Empty Name Skill
`;
await fs.writeFile(path.join(skillDir, "SKILL.md"), skillContent, "utf-8");

const context = resolveBundledSkillsContext();

// Should not include empty/whitespace-only names
expect(context.names.size).toBe(0);
});
});
6 changes: 4 additions & 2 deletions src/agents/skills/bundled-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export function resolveBundledSkillsContext(
}
const result = loadSkillsFromDir({ dir, source: "openclaw-bundled" });
for (const skill of result.skills) {
if (skill.name.trim()) {
names.add(skill.name);
// Ensure skill name is a string (YAML may parse bare numbers as integers)
const skillName = String(skill.name);
if (skillName.trim()) {
names.add(skillName);
}
}
cachedBundledContext = { dir, names: new Set(names) };
Expand Down
4 changes: 3 additions & 1 deletion src/agents/skills/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export function isBundledSkillAllowed(entry: SkillEntry, allowlist?: string[]):
return true;
}
const key = resolveSkillKey(entry.skill, entry);
return allowlist.includes(key) || allowlist.includes(entry.skill.name);
// Ensure skill name is a string (YAML may parse bare numbers as integers)
const skillName = String(entry.skill.name);
return allowlist.includes(key) || allowlist.includes(skillName);
}

export function shouldIncludeSkill(params: {
Expand Down
3 changes: 2 additions & 1 deletion src/agents/skills/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ function filterSkillEntries(
skillsLogger.debug(`Applying skill filter: ${label}`);
filtered =
normalized.length > 0
? filtered.filter((entry) => normalized.includes(entry.skill.name))
? // Ensure skill name is a string (YAML may parse bare numbers as integers)
filtered.filter((entry) => normalized.includes(String(entry.skill.name)))
: [];
skillsLogger.debug(
`After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`,
Expand Down