250 lines
8.6 KiB
TypeScript
250 lines
8.6 KiB
TypeScript
import type { Part } from '@opencode-ai/sdk/v2';
|
|
import { readContextPart } from '@/lib/messages/contextParts';
|
|
import {
|
|
GITHUB_ISSUE_CONTEXT_PREFIX,
|
|
GITHUB_PR_CONTEXT_PREFIX,
|
|
GITLAB_ISSUE_CONTEXT_PREFIX,
|
|
GITLAB_MR_CONTEXT_PREFIX,
|
|
GITEA_ISSUE_CONTEXT_PREFIX,
|
|
GITEA_PR_CONTEXT_PREFIX,
|
|
startsWithForgeContextPrefix,
|
|
} from '@/lib/messages/synthetic';
|
|
|
|
type IssueContextPayload = {
|
|
issue?: {
|
|
number?: unknown;
|
|
title?: unknown;
|
|
url?: unknown;
|
|
};
|
|
};
|
|
|
|
type GitHubPrContextPayload = {
|
|
pr?: {
|
|
number?: unknown;
|
|
title?: unknown;
|
|
url?: unknown;
|
|
};
|
|
};
|
|
|
|
type GitLabMrContextPayload = {
|
|
mr?: {
|
|
number?: unknown;
|
|
title?: unknown;
|
|
url?: unknown;
|
|
};
|
|
};
|
|
|
|
type GiteaPrContextPayload = {
|
|
pr?: {
|
|
number?: unknown;
|
|
title?: unknown;
|
|
url?: unknown;
|
|
};
|
|
};
|
|
|
|
const isPositiveNumber = (value: unknown): value is number => {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
};
|
|
|
|
const parseSyntheticJsonPayload = <T>(text: string, prefix: string): T | null => {
|
|
const normalizedText = text.trimStart();
|
|
if (!normalizedText.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
|
|
const jsonStart = normalizedText.indexOf('{');
|
|
if (jsonStart < 0) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(normalizedText.slice(jsonStart)) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const buildForgeAttachmentPart = (text: string): Part | null => {
|
|
// GitHub issues
|
|
const ghIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
|
if (ghIssuePayload) {
|
|
const issue = ghIssuePayload.issue;
|
|
const number = issue?.number;
|
|
const title = issue?.title;
|
|
const url = issue?.url;
|
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'file',
|
|
mime: 'application/vnd.github.issue-link',
|
|
filename: `Issue #${number}: ${title}`,
|
|
url,
|
|
} as Part;
|
|
}
|
|
|
|
// GitHub PRs
|
|
const ghPrPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
|
if (ghPrPayload) {
|
|
const pr = ghPrPayload.pr;
|
|
const number = pr?.number;
|
|
const title = pr?.title;
|
|
const url = pr?.url;
|
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'file',
|
|
mime: 'application/vnd.github.pull-request-link',
|
|
filename: `PR #${number}: ${title}`,
|
|
url,
|
|
} as Part;
|
|
}
|
|
|
|
// GitLab issues
|
|
const glIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITLAB_ISSUE_CONTEXT_PREFIX);
|
|
if (glIssuePayload) {
|
|
const issue = glIssuePayload.issue;
|
|
const number = issue?.number;
|
|
const title = issue?.title;
|
|
const url = issue?.url;
|
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'file',
|
|
mime: 'application/vnd.gitlab.issue-link',
|
|
filename: `Issue #${number}: ${title}`,
|
|
url,
|
|
} as Part;
|
|
}
|
|
|
|
// GitLab MRs
|
|
const glMrPayload = parseSyntheticJsonPayload<GitLabMrContextPayload>(text, GITLAB_MR_CONTEXT_PREFIX);
|
|
if (glMrPayload) {
|
|
const mr = glMrPayload.mr;
|
|
const number = mr?.number;
|
|
const title = mr?.title;
|
|
const url = mr?.url;
|
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'file',
|
|
mime: 'application/vnd.gitlab.merge-request-link',
|
|
filename: `MR !${number}: ${title}`,
|
|
url,
|
|
} as Part;
|
|
}
|
|
|
|
// Gitea issues
|
|
const gtIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITEA_ISSUE_CONTEXT_PREFIX);
|
|
if (gtIssuePayload) {
|
|
const issue = gtIssuePayload.issue;
|
|
const number = issue?.number;
|
|
const title = issue?.title;
|
|
const url = issue?.url;
|
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'file',
|
|
mime: 'application/vnd.gitea.issue-link',
|
|
filename: `Issue #${number}: ${title}`,
|
|
url,
|
|
} as Part;
|
|
}
|
|
|
|
// Gitea PRs
|
|
const gtPrPayload = parseSyntheticJsonPayload<GiteaPrContextPayload>(text, GITEA_PR_CONTEXT_PREFIX);
|
|
if (gtPrPayload) {
|
|
const pr = gtPrPayload.pr;
|
|
const number = pr?.number;
|
|
const title = pr?.title;
|
|
const url = pr?.url;
|
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'file',
|
|
mime: 'application/vnd.gitea.pull-request-link',
|
|
filename: `PR #${number}: ${title}`,
|
|
url,
|
|
} as Part;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const shouldKeepSyntheticUserText = (text: string, planModeEnabled: boolean): boolean => {
|
|
const trimmed = text.trim();
|
|
if (planModeEnabled && trimmed.startsWith('User has requested to enter plan mode')) return true;
|
|
if (planModeEnabled && trimmed.startsWith('The plan at ')) return true;
|
|
if (trimmed.startsWith('The following tool was executed by the user')) return true;
|
|
return false;
|
|
};
|
|
|
|
export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEnabled?: boolean }): Part[] => {
|
|
const planModeEnabled = options?.planModeEnabled === true;
|
|
return parts
|
|
.filter((part) => {
|
|
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
|
if (!synthetic) return true;
|
|
if (part.type !== 'text') return false;
|
|
if (readContextPart(part)) return true;
|
|
const text = (part as { text?: unknown }).text;
|
|
if (typeof text !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const normalizedText = text.trimStart();
|
|
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
|
|| startsWithForgeContextPrefix(normalizedText);
|
|
})
|
|
.map((part) => {
|
|
const rawPart = part as Record<string, unknown>;
|
|
if (rawPart.type === 'compaction') {
|
|
return { type: 'text', text: '/compact' } as Part;
|
|
}
|
|
if (rawPart.type === 'text') {
|
|
const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : '';
|
|
const synthetic = rawPart.synthetic === true;
|
|
|
|
if (synthetic) {
|
|
const contextPayload = readContextPart(part);
|
|
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
|
// SAFETY: same display-only file-part shape the legacy
|
|
// buildGitHubAttachmentPart produces; consumed by
|
|
// FileAttachment, which matches on the mime type.
|
|
return {
|
|
type: 'file',
|
|
mime: contextPayload.kind === 'github-issue'
|
|
? 'application/vnd.github.issue-link'
|
|
: 'application/vnd.github.pull-request-link',
|
|
filename: contextPayload.kind === 'github-issue'
|
|
? `Issue #${contextPayload.number}: ${contextPayload.title}`
|
|
: `PR #${contextPayload.number}: ${contextPayload.title}`,
|
|
url: contextPayload.url,
|
|
} as Part;
|
|
}
|
|
if (contextPayload) {
|
|
// Other context kinds render through UserContextPart.
|
|
return part;
|
|
}
|
|
// Legacy messages: sniff the pre-metadata text format.
|
|
// buildForgeAttachmentPart covers GitHub, GitLab, and Gitea
|
|
// issue/PR/MR links (custom's multi-forge support).
|
|
const attachmentPart = buildForgeAttachmentPart(text);
|
|
if (attachmentPart) {
|
|
return attachmentPart;
|
|
}
|
|
}
|
|
|
|
if (text.startsWith('The following tool was executed by the user')) {
|
|
return { type: 'text', text: '/shell' } as Part;
|
|
}
|
|
}
|
|
return part;
|
|
});
|
|
};
|