Skip to content
Merged
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
5 changes: 3 additions & 2 deletions packages/conversation-ui-core/src/composer-input-parts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseLocalFileReference } from "./message-body.js";
import { trimBoundaryCharacters } from "./string-boundaries.js";

export type ComposerInputPart =
| { readonly type: "text"; readonly text: string }
Expand Down Expand Up @@ -254,7 +255,7 @@ function mergeAdjacentTextParts(parts: readonly ComposerInputPart[]): readonly C
}

function normalizeLabel(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return trimBoundaryCharacters(value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-"), "-");
}

function readKnownMentionAtStart(
Expand Down Expand Up @@ -319,7 +320,7 @@ function readUrlAtStart(
}

function trimUrlToken(value: string): string {
let next = trimNaturalLanguageSuffix(value).replace(/[.,;:!?,。!?;:、]+$/u, "");
let next = trimBoundaryCharacters(trimNaturalLanguageSuffix(value), ".,;:!?,。!?;:、", "end");
while (next.endsWith(")") || next.endsWith("]")) {
const opener = next.endsWith(")") ? "(" : "[";
const closer = next.endsWith(")") ? ")" : "]";
Expand Down
5 changes: 3 additions & 2 deletions packages/conversation-ui-core/src/message-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { trimBoundaryCharacters } from "./string-boundaries.js";

export interface LocalFileReference {
readonly raw: string;
Expand Down Expand Up @@ -521,11 +522,11 @@ function stripWrappingQuotes(value: string): string {
}

function trimTrailingUrlPunctuation(value: string): string {
return value.replace(/[),.;!?]+$/g, "");
return trimBoundaryCharacters(value, "),.;!?", "end");
}

function trimTrailingPathPunctuation(value: string): string {
return value.replace(/[),.;!?]+$/g, "");
return trimBoundaryCharacters(value, "),.;!?", "end");
}

function normalizePath(value: string): string {
Expand Down
74 changes: 74 additions & 0 deletions packages/conversation-ui-core/src/string-boundaries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export function trimBoundaryCharacters(
value: string,
characters: string,
side: "start" | "end" | "both" = "both",
): string {
let start = 0;
let end = value.length;
if (side !== "end") {
while (start < end && characters.includes(value[start] ?? "")) start += 1;
}
if (side !== "start") {
while (end > start && characters.includes(value[end - 1] ?? "")) end -= 1;
}
return start === 0 && end === value.length ? value : value.slice(start, end);
}

export function extractWrappedSection(
value: string,
labels: readonly string[],
boundaryLabels: readonly string[],
): string | undefined {
const searchValue = asciiLowercase(value);
let selected: { readonly labelIndex: number; readonly start: number } | undefined;
for (const label of labels) {
const searchLabel = asciiLowercase(label);
let labelIndex = searchValue.indexOf(searchLabel);
while (labelIndex !== -1) {
let start = labelIndex + label.length;
while (start < value.length && isWhitespace(value[start])) start += 1;
if ("::".includes(value[start] ?? "")) {
start += 1;
while (start < value.length && isWhitespace(value[start])) start += 1;
if (!selected || labelIndex < selected.labelIndex) selected = { labelIndex, start };
break;
}
labelIndex = searchValue.indexOf(searchLabel, labelIndex + 1);
}
}
if (!selected) return undefined;
let end = value.length;
for (const boundary of boundaryLabels) {
const searchBoundary = asciiLowercase(boundary);
let boundaryIndex = searchValue.indexOf(searchBoundary, selected.start);
while (boundaryIndex !== -1) {
const before = value[boundaryIndex - 1];
const after = value[boundaryIndex + boundary.length];
if (isWhitespace(before) && !isWordCharacter(after)) {
end = Math.min(end, boundaryIndex);
break;
}
boundaryIndex = searchValue.indexOf(searchBoundary, boundaryIndex + 1);
}
}
const section = value.slice(selected.start, end).trim();
return section || undefined;
}

function asciiLowercase(value: string): string {
const result: string[] = [];
for (let index = 0; index < value.length; index += 1) {
const character = value[index] ?? "";
const code = character.charCodeAt(0);
result.push(code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : character);
}
return result.join("");
}

function isWhitespace(value: string | undefined): boolean {
return value !== undefined && value.trim() === "";
}

function isWordCharacter(value: string | undefined): boolean {
return value !== undefined && /[A-Za-z0-9_]/u.test(value);
}
16 changes: 12 additions & 4 deletions packages/conversation-ui-core/src/terminal-report-view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
userVisibleTerminalReportChanges,
} from "./terminal-report-visibility.js";
import { projectVisibleTerminalReportContent } from "./terminal-report-presentation.js";
import { extractWrappedSection, trimBoundaryCharacters } from "./string-boundaries.js";

interface TerminalReportSourceRefInput {
readonly kind: string;
Expand Down Expand Up @@ -300,10 +301,17 @@ function uniqueSourceRefs(refs: readonly TerminalReportSourceRefInput[]): Termin
*/
function compactTerminalReportTitle(value: string): string {
const normalized = value.replace(/\s+/gu, " ").trim();
const wrapped = normalized.match(
/(?:原始请求|Original request)\s*[::]\s*(.+?)(?=\s+(?:执行任务|已物化的执行操作|建议协作步骤|Requested objective|No literal file content|Materialized operations|Participant operation ownership|Permission boundary)\b|$)/iu,
)?.[1]?.trim();
const candidate = (wrapped || normalized).replace(/^\*+|\*+$/gu, "").trim();
const wrapped = extractWrappedSection(normalized, ["原始请求", "Original request"], [
"执行任务",
"已物化的执行操作",
"建议协作步骤",
"Requested objective",
"No literal file content",
"Materialized operations",
"Participant operation ownership",
"Permission boundary",
]);
const candidate = trimBoundaryCharacters(wrapped || normalized, "*").trim();
return candidate.length > 180 ? `${candidate.slice(0, 177).trimEnd()}...` : candidate;
}

Expand Down
12 changes: 12 additions & 0 deletions packages/conversation-ui-core/test/composer-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ test("raw token removal trims duplicate whitespace at removal boundary", () => {
assert.equal(removeComposerInputRawToken("Run /review now", "/review"), "Run now");
});

test("composer parsing handles long boundary runs without changing tokens", () => {
const punctuation = "!".repeat(100_000);
const parts = parseComposerInputParts(`https://example.com/docs${punctuation}`);
assert.equal(parts[0]?.type, "url_ref");
assert.equal(parts[0]?.raw, "https://example.com/docs");

const paddedId = `${"-".repeat(100_000)}review${"-".repeat(100_000)}`;
const command = parseComposerInputParts("/review", { slashCommands: [{ id: paddedId, label: "Review" }] });
assert.equal(command[0]?.type, "slash_command");
assert.equal(command[0]?.commandId, paddedId);
});

test("permission confirmation is derived from explicit option metadata", () => {
assert.equal(conversationPermissionPresetRequiresConfirmation("full_access"), true);
assert.equal(conversationPermissionPresetRequiresConfirmation("read_only"), false);
Expand Down
9 changes: 9 additions & 0 deletions packages/conversation-ui-core/test/message-body.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,12 @@ test("message body collects unique file references from nested structured blocks
"docs/design/conversation-shared-capabilities.md",
]);
});

test("message tokenization trims long punctuation runs in linear boundary helpers", () => {
const punctuation = "!".repeat(100_000);
const tokens = splitInlineTokens(`https://example.com/docs${punctuation} src/index.ts${punctuation}`, "/workspace/ling-stack");
const link = tokens.find((token) => token.kind === "web_link");
const file = tokens.find((token) => token.kind === "local_file");
assert.equal(link?.kind === "web_link" ? link.href : undefined, "https://example.com/docs");
assert.equal(file?.kind === "local_file" ? file.reference.displayPath : undefined, "src/index.ts");
});
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,20 @@ test("terminal report projection keeps Markdown summaries and bounds model title
assert.match(participant?.description ?? "", /```ts\nconst ok = true;/u);
});

test("terminal report title extraction handles long protocol whitespace", () => {
const title = `Original request${" ".repeat(100_000)}: concise result Requested objective ignored wrapper`;
const report = terminalReportToViewModel({
id: "terminal_report_long_wrapper",
status: "committed",
language: "en-US",
outcome: { status: "succeeded", title, summary: "done", corrections: [], sourceRefs: [] },
changes: [], verification: [], participants: [], artifacts: [], risks: [], references: [],
evidenceFingerprint: "fingerprint_long_wrapper",
committedAt: "2026-10-21T00:00:00.000Z",
});
assert.equal(report.rows.find((row) => row.reportSection === "outcome")?.title, "concise result");
});

test("terminal report artifact workspace paths require one trusted changed-file source ref", () => {
const report = terminalReportToViewModel({
id: "terminal_report_workspace_files",
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/channel-team-snapshot-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function inferRiskReview(input: string): Parameters<typeof buildChannelTeamRiskR
},
};
}
if (/(delete|drop|remove).*(production|database|db)|rm\s+-rf|reset\s+--hard/.test(lower)) {
if (hasDestructiveTargetSequence(lower) || /rm\s+-rf|reset\s+--hard/.test(lower)) {
return {
actionType: "bash",
risk: "critical",
Expand All @@ -129,6 +129,18 @@ function inferRiskReview(input: string): Parameters<typeof buildChannelTeamRiskR
};
}

function hasDestructiveTargetSequence(value: string): boolean {
for (const action of ["delete", "drop", "remove"] as const) {
const actionIndex = value.indexOf(action);
if (actionIndex === -1) continue;
const targetStart = actionIndex + action.length;
if (["production", "database", "db"].some((target) => value.indexOf(target, targetStart) !== -1)) {
return true;
}
}
return false;
}

function diffValue(path: string, before: unknown, after: unknown): readonly ChannelTeamSnapshotDiffEntry[] {
if (JSON.stringify(before) === JSON.stringify(after)) {
return [];
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/composer-slash-intents.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { trimBoundaryCharacters } from "./string-boundaries.js";

export type ComposerSlashIntent =
| { readonly kind: "none" }
| {
Expand Down Expand Up @@ -314,7 +316,7 @@ function isSessionCommandOption(command: ComposerSlashCommandOption): boolean {
}

function normalizeLabel(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return trimBoundaryCharacters(value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-"), "-");
}

function teammateMentionAliases(teammate: ComposerSlashMentionOption): readonly string[] {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/external-work-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { relative, resolve as resolvePath } from "node:path";
import type { ApprovalRequest } from "./approval-types.js";
import { redactSecrets } from "./redaction.js";
import { trimBoundaryCharacters } from "./string-boundaries.js";
import { DEFAULT_WORKSPACE_ID } from "./workspace-types.js";
import { normalizeGitHubExternalWorkItem } from "./external-work.js";
import type {
Expand Down Expand Up @@ -145,7 +146,7 @@ export function createGitHubExternalActionAdapter(options: GitHubExternalActionA
if (!fetchImpl) {
throw new Error("A fetch implementation is required for GitHub external action execution.");
}
const apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/+$/, "");
const apiBaseUrl = trimBoundaryCharacters(options.apiBaseUrl ?? "https://api.github.com", "/", "end");

return {
async getPullRequest(input: GitHubSubjectInput): Promise<ExternalWorkItem | undefined> {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/external-work-ci-triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { relative, resolve as resolvePath } from "node:path";
import { normalizeGitHubExternalWorkItem } from "./external-work.js";
import { redactSecrets } from "./redaction.js";
import { trimBoundaryCharacters } from "./string-boundaries.js";
import type {
ExternalWorkCiTriageRecord,
ExternalWorkCiTriageResponse,
Expand Down Expand Up @@ -85,7 +86,7 @@ export function createGitHubCiTriageAdapter(options: GitHubCiTriageAdapterOption
if (!fetchImpl) {
throw new Error("A fetch implementation is required for GitHub CI triage.");
}
const apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/+$/, "");
const apiBaseUrl = trimBoundaryCharacters(options.apiBaseUrl ?? "https://api.github.com", "/", "end");

return {
async getPullRequest(input): Promise<ExternalWorkItem> {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/external-work-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
import { DEFAULT_WORKSPACE_ID, type Channel } from "./workspace-types.js";
import { redactSecrets } from "./redaction.js";
import { trimBoundaryCharacters } from "./string-boundaries.js";
import { createCoreOriginalProcessEntry, isCoreOriginalLaunchRefusal, type CoreOriginalProcessEntry } from "./original-host-process-entry.js";
import type {
ChannelStoreFacet,
Expand Down Expand Up @@ -117,7 +118,7 @@ export function createGitHubExternalWorkAdapter(options: GitHubExternalWorkAdapt
if (!fetchImpl) {
throw new Error("A fetch implementation is required for GitHub external work polling.");
}
const apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/+$/, "");
const apiBaseUrl = trimBoundaryCharacters(options.apiBaseUrl ?? "https://api.github.com", "/", "end");

return {
id: "github-readonly",
Expand Down
46 changes: 37 additions & 9 deletions packages/core/src/external-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,45 @@ export function updateExternalWorkflowSecretRef(markdown: string, secretRef: str
}

export function renderExternalWorkflowPrompt(template: string, input: { readonly item: ExternalWorkItem }): string {
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, expression: string) => {
const path = expression.trim();
if (!path.startsWith("item.")) {
throw new ExternalWorkflowValidationError(`Unknown workflow prompt variable: ${path}`);
const parts: string[] = [];
let cursor = 0;
let opening: number | undefined;
let index = 0;
while (index < template.length) {
const character = template[index];
const nextCharacter = template[index + 1];
if (character === "{" && nextCharacter === "{") {
if (opening === undefined) opening = index;
index += 1;
continue;
}
const value = readDotPath(input, path);
if (value === undefined || value === null) {
throw new ExternalWorkflowValidationError(`Unknown workflow prompt variable: ${path}`);
if (character !== "}") {
index += 1;
continue;
}
return formatPromptValue(value);
});
if (nextCharacter !== "}") {
opening = undefined;
index += 1;
continue;
}
if (opening !== undefined && index > opening + 2) {
parts.push(template.slice(cursor, opening));
const path = template.slice(opening + 2, index).trim();
if (!path.startsWith("item.")) {
throw new ExternalWorkflowValidationError(`Unknown workflow prompt variable: ${path}`);
}
const value = readDotPath(input, path);
if (value === undefined || value === null) {
throw new ExternalWorkflowValidationError(`Unknown workflow prompt variable: ${path}`);
}
parts.push(formatPromptValue(value));
cursor = index + 2;
}
opening = undefined;
index += 2;
}
parts.push(template.slice(cursor));
return parts.join("");
}

export function normalizeGitHubExternalWorkItem(input: {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/managed-official-skill-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
import { promisify } from "node:util";
import type { SkillSourceScanRoot } from "./skill-types.js";
import { createCoreOriginalProcessEntry, isCoreOriginalLaunchRefusal } from "./original-host-process-entry.js";
import { trimBoundaryCharacters } from "./string-boundaries.js";

const execFile = promisify(execFileCallback);

Expand Down Expand Up @@ -179,7 +180,7 @@ async function readGstackVersion(repoRoot: string): Promise<string | undefined>

function normalizeGithubPath(host: string, pathname: string): string | undefined {
const normalizedHost = host.trim().toLowerCase();
const normalizedPath = pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase();
const normalizedPath = trimBoundaryCharacters(pathname, "/").replace(/\.git$/i, "").toLowerCase();
return normalizedHost && normalizedPath ? `${normalizedHost}/${normalizedPath}` : undefined;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/memory-markdown-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
MemorySourceRef,
} from "./memory-types.js";
import { DEFAULT_WORKSPACE_ID } from "./workspace-types.js";
import { trimBoundaryCharacters } from "./string-boundaries.js";

export interface MemoryMarkdownImportOptions {
readonly workspaceId?: string;
Expand Down Expand Up @@ -374,7 +375,7 @@ function importSourceRefId(sourceId: string, itemId: string): string {
}

function sanitizeSourceId(value: string): string {
const sanitized = value.trim().replace(/[^A-Za-z0-9._:-]+/g, "_").replace(/^_+|_+$/g, "");
const sanitized = trimBoundaryCharacters(value.trim().replace(/[^A-Za-z0-9._:-]+/g, "_"), "_");
return sanitized || "memory-markdown-import";
}

Expand Down
Loading
Loading