kimaki 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/anthropic-auth-plugin.js +52 -18
- package/dist/format-tables.test.js +19 -0
- package/dist/message-formatting.js +3 -1
- package/dist/message-formatting.test.js +43 -1
- package/dist/proxy-ws-preload.cjs +85 -0
- package/package.json +4 -4
- package/src/anthropic-auth-plugin.ts +64 -20
- package/src/format-tables.test.ts +20 -0
- package/src/message-formatting.test.ts +46 -1
- package/src/message-formatting.ts +3 -1
|
@@ -65,6 +65,9 @@ const CLAUDE_CODE_VERSION = "2.1.75";
|
|
|
65
65
|
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
66
66
|
const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
|
|
67
67
|
const ANTHROPIC_PROMPT_MARKER = "Skills provide specialized instructions";
|
|
68
|
+
// Subagent prompts don't contain OPENCODE_IDENTITY; opencode appends this
|
|
69
|
+
// line + an <env> block instead. We strip from here to </env> inclusive.
|
|
70
|
+
const SUBAGENT_MODEL_IDENTITY = "You are powered by the model named";
|
|
68
71
|
const CLAUDE_CODE_BETA = "claude-code-20250219";
|
|
69
72
|
const OAUTH_BETA = "oauth-2025-04-20";
|
|
70
73
|
const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
|
|
@@ -477,28 +480,59 @@ function toClaudeCodeToolName(name) {
|
|
|
477
480
|
*/
|
|
478
481
|
function sanitizeAnthropicSystemText(text, onError) {
|
|
479
482
|
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
480
|
-
if (startIdx
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
483
|
+
if (startIdx !== -1) {
|
|
484
|
+
// Main session path: strip from OpenCode identity to the Anthropic prompt marker.
|
|
485
|
+
// Keep the marker aligned with the current OpenCode Anthropic prompt.
|
|
486
|
+
const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
|
|
487
|
+
if (endIdx === -1) {
|
|
488
|
+
onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
|
|
489
|
+
return text;
|
|
490
|
+
}
|
|
491
|
+
return replaceBlockWithCompactEnv(text, startIdx, endIdx);
|
|
492
|
+
}
|
|
493
|
+
// Subagent path: opencode appends "You are powered by the model named ..."
|
|
494
|
+
// followed by an <env> block. Strip from that line through </env>.
|
|
495
|
+
const subagentIdx = text.indexOf(SUBAGENT_MODEL_IDENTITY);
|
|
496
|
+
if (subagentIdx !== -1) {
|
|
497
|
+
const envCloseTag = "</env>";
|
|
498
|
+
const envCloseIdx = text.indexOf(envCloseTag, subagentIdx);
|
|
499
|
+
if (envCloseIdx === -1) {
|
|
500
|
+
onError?.("sanitizeAnthropicSystemText: could not find </env> after subagent model identity");
|
|
501
|
+
return text;
|
|
502
|
+
}
|
|
503
|
+
const endIdx = envCloseIdx + envCloseTag.length;
|
|
504
|
+
// Skip trailing newline so the join is clean
|
|
505
|
+
const afterEnd = text[endIdx] === "\n" ? endIdx + 1 : endIdx;
|
|
506
|
+
return replaceBlockWithCompactEnv(text, subagentIdx, afterEnd);
|
|
487
507
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
508
|
+
return text;
|
|
509
|
+
}
|
|
510
|
+
// Extract cwd from the block being stripped and replace it with a compact
|
|
511
|
+
// <environment> tag. Shared by both main-session and subagent paths.
|
|
512
|
+
// Source: anomalyco/opencode packages/opencode/src/session/system.ts
|
|
513
|
+
// OpenCode's system prompt format (as of 2025):
|
|
514
|
+
// <env>
|
|
515
|
+
// Working directory: ${Instance.directory}
|
|
516
|
+
// Workspace root folder: ${Instance.worktree}
|
|
517
|
+
// Is directory a git repo: yes/no
|
|
518
|
+
// Platform: ${process.platform}
|
|
519
|
+
// Today's date: ${new Date().toDateString()}
|
|
520
|
+
// </env>
|
|
521
|
+
// Older format used <environment><cwd>/path</cwd></environment>.
|
|
522
|
+
// We try both patterns to stay compatible across opencode versions.
|
|
523
|
+
// We preserve the per-session directory instead of falling back to
|
|
524
|
+
// process.cwd() which is the opencode server's cwd and wrong for
|
|
525
|
+
// multi-session/worktree setups where each session has a different directory.
|
|
526
|
+
function replaceBlockWithCompactEnv(text, startIdx, endIdx) {
|
|
493
527
|
const strippedBlock = text.slice(startIdx, endIdx);
|
|
494
|
-
const cwdMatch = strippedBlock.match(
|
|
495
|
-
|
|
528
|
+
const cwdMatch = strippedBlock.match(/Working directory:\s*(.+)/)?.[1]?.trim() ||
|
|
529
|
+
strippedBlock.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
|
|
530
|
+
const cwd = cwdMatch || process.cwd();
|
|
496
531
|
const envContext = `\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
|
|
497
|
-
`Read, write, and edit files under
|
|
498
|
-
|
|
532
|
+
`Read, write, and edit files under ${cwd}.\n\n`;
|
|
533
|
+
return (text.slice(0, startIdx) +
|
|
499
534
|
envContext +
|
|
500
|
-
text.slice(endIdx);
|
|
501
|
-
return result;
|
|
535
|
+
text.slice(endIdx));
|
|
502
536
|
}
|
|
503
537
|
function mapSystemTextPart(part, onError) {
|
|
504
538
|
if (typeof part === "string") {
|
|
@@ -439,6 +439,25 @@ Read this first.
|
|
|
439
439
|
]
|
|
440
440
|
`);
|
|
441
441
|
});
|
|
442
|
+
test('renders callout that was prefixed with ⬥ as plain text (regression)', () => {
|
|
443
|
+
// Before the fix, formatPart would add ⬥ prefix to callout lines,
|
|
444
|
+
// breaking the callout parser. Now formatPart skips the prefix for callouts.
|
|
445
|
+
const result = splitTablesFromMarkdown(`⬥ <callout accent="#ef4444">
|
|
446
|
+
## Top priority
|
|
447
|
+
- **Stripe dispute** deadline
|
|
448
|
+
</callout>`);
|
|
449
|
+
expect(result).toMatchInlineSnapshot(`
|
|
450
|
+
[
|
|
451
|
+
{
|
|
452
|
+
"text": "⬥ <callout accent="#ef4444">
|
|
453
|
+
## Top priority
|
|
454
|
+
- **Stripe dispute** deadline
|
|
455
|
+
</callout>",
|
|
456
|
+
"type": "text",
|
|
457
|
+
},
|
|
458
|
+
]
|
|
459
|
+
`);
|
|
460
|
+
});
|
|
442
461
|
test('falls back to plain text when a callout is not closed', () => {
|
|
443
462
|
const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
|
|
444
463
|
## Important
|
|
@@ -307,7 +307,9 @@ export function formatPart(part, prefix) {
|
|
|
307
307
|
}
|
|
308
308
|
const firstChar = text[0] || '';
|
|
309
309
|
const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
|
|
310
|
-
const startsWithMarkdown = markdownStarters.includes(firstChar) ||
|
|
310
|
+
const startsWithMarkdown = markdownStarters.includes(firstChar) ||
|
|
311
|
+
/^\d+\./.test(text) ||
|
|
312
|
+
/^<callout[\s>]/i.test(text);
|
|
311
313
|
if (startsWithMarkdown) {
|
|
312
314
|
return `\n${text}`;
|
|
313
315
|
}
|
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
import { describe, test, expect } from 'vitest';
|
|
2
|
-
import { formatTodoList } from './message-formatting.js';
|
|
2
|
+
import { formatPart, formatTodoList } from './message-formatting.js';
|
|
3
|
+
describe('formatPart', () => {
|
|
4
|
+
test('callout text does not get ⬥ prefix', () => {
|
|
5
|
+
const part = {
|
|
6
|
+
id: 'test',
|
|
7
|
+
type: 'text',
|
|
8
|
+
sessionID: 'ses_test',
|
|
9
|
+
messageID: 'msg_test',
|
|
10
|
+
text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
|
|
11
|
+
};
|
|
12
|
+
expect(formatPart(part)).toMatchInlineSnapshot(`
|
|
13
|
+
"
|
|
14
|
+
<callout accent="#ef4444">
|
|
15
|
+
## Top priority
|
|
16
|
+
- **Stripe dispute** deadline
|
|
17
|
+
</callout>"
|
|
18
|
+
`);
|
|
19
|
+
});
|
|
20
|
+
test('regular text gets ⬥ prefix', () => {
|
|
21
|
+
const part = {
|
|
22
|
+
id: 'test',
|
|
23
|
+
type: 'text',
|
|
24
|
+
sessionID: 'ses_test',
|
|
25
|
+
messageID: 'msg_test',
|
|
26
|
+
text: 'hello world',
|
|
27
|
+
};
|
|
28
|
+
expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`);
|
|
29
|
+
});
|
|
30
|
+
test('text starting with heading does not get ⬥ prefix', () => {
|
|
31
|
+
const part = {
|
|
32
|
+
id: 'test',
|
|
33
|
+
type: 'text',
|
|
34
|
+
sessionID: 'ses_test',
|
|
35
|
+
messageID: 'msg_test',
|
|
36
|
+
text: '## Summary\nDone.',
|
|
37
|
+
};
|
|
38
|
+
expect(formatPart(part)).toMatchInlineSnapshot(`
|
|
39
|
+
"
|
|
40
|
+
## Summary
|
|
41
|
+
Done."
|
|
42
|
+
`);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
3
45
|
describe('formatTodoList', () => {
|
|
4
46
|
test('formats active todo with monospace numbers', () => {
|
|
5
47
|
const part = {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// WebSocket proxy preload for environments behind a firewall or VPN (e.g. GFW).
|
|
2
|
+
// Loaded via --require when HTTPS_PROXY / https_proxy / HTTP_PROXY / http_proxy is set.
|
|
3
|
+
//
|
|
4
|
+
// Monkey-patches Module.prototype.require to intercept `ws` module loads and
|
|
5
|
+
// inject an https-proxy-agent into every client-side WebSocket connection.
|
|
6
|
+
// This is needed because:
|
|
7
|
+
// 1. Node.js --use-env-proxy breaks ws WebSocket upgrades (nodejs/node#62054)
|
|
8
|
+
// 2. discord.js doesn't expose an agent option for WebSocket connections
|
|
9
|
+
// 3. @discordjs/ws imports ws via ESM, so require.cache patches don't work
|
|
10
|
+
// 4. The ws library supports agent in options but discord.js doesn't pass it
|
|
11
|
+
//
|
|
12
|
+
// Tracking issues:
|
|
13
|
+
// - nodejs/node#57872 (proxy env var support in Node.js)
|
|
14
|
+
// - nodejs/node#62054 (--use-env-proxy breaks ws)
|
|
15
|
+
// - discordjs/discord.js#10716 (proxy support for WebSocket, closed won't fix)
|
|
16
|
+
'use strict';
|
|
17
|
+
const proxyUrl = process.env.https_proxy ||
|
|
18
|
+
process.env.HTTPS_PROXY ||
|
|
19
|
+
process.env.http_proxy ||
|
|
20
|
+
process.env.HTTP_PROXY;
|
|
21
|
+
if (!proxyUrl) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let HttpsProxyAgent;
|
|
25
|
+
try {
|
|
26
|
+
HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// https-proxy-agent not installed; skip patching
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const agent = new HttpsProxyAgent(proxyUrl);
|
|
33
|
+
const Module = require('module');
|
|
34
|
+
const origRequire = Module.prototype.require;
|
|
35
|
+
Module.prototype.require = function patchedRequire(id) {
|
|
36
|
+
const mod = origRequire.apply(this, arguments);
|
|
37
|
+
// Only intercept the ws module, and only once
|
|
38
|
+
if (id === 'ws' && mod && !mod.__kimakiProxyPatched) {
|
|
39
|
+
const OrigWS = mod.WebSocket || mod;
|
|
40
|
+
if (typeof OrigWS === 'function' && !OrigWS.__kimakiProxyPatched) {
|
|
41
|
+
const PatchedWS = function ProxiedWebSocket(url, protocols, options) {
|
|
42
|
+
// ws allows protocols to be an options object when omitted
|
|
43
|
+
if (typeof protocols === 'object' &&
|
|
44
|
+
protocols !== null &&
|
|
45
|
+
!Array.isArray(protocols)) {
|
|
46
|
+
options = protocols;
|
|
47
|
+
protocols = undefined;
|
|
48
|
+
}
|
|
49
|
+
// Inject agent for client connections only (url !== null).
|
|
50
|
+
// Don't override if caller already provided an agent.
|
|
51
|
+
if (url !== null && (!options || !options.agent)) {
|
|
52
|
+
options = Object.assign({}, options, { agent });
|
|
53
|
+
}
|
|
54
|
+
if (new.target) {
|
|
55
|
+
return Reflect.construct(OrigWS, [url, protocols, options], new.target);
|
|
56
|
+
}
|
|
57
|
+
return new OrigWS(url, protocols, options);
|
|
58
|
+
};
|
|
59
|
+
// Inherit prototype so instanceof checks work
|
|
60
|
+
Object.setPrototypeOf(PatchedWS.prototype, OrigWS.prototype);
|
|
61
|
+
Object.setPrototypeOf(PatchedWS, OrigWS);
|
|
62
|
+
PatchedWS.prototype.constructor = PatchedWS;
|
|
63
|
+
// Copy static constants (CONNECTING, OPEN, CLOSING, CLOSED) and statics
|
|
64
|
+
for (const key of Object.getOwnPropertyNames(OrigWS)) {
|
|
65
|
+
if (key !== 'length' && key !== 'prototype' && key !== 'name') {
|
|
66
|
+
try {
|
|
67
|
+
Object.defineProperty(PatchedWS, key, Object.getOwnPropertyDescriptor(OrigWS, key));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// some properties may not be configurable
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
PatchedWS.__kimakiProxyPatched = true;
|
|
75
|
+
mod.WebSocket = PatchedWS;
|
|
76
|
+
// ws/index.js sets module.exports = WebSocket, so patch the default too
|
|
77
|
+
if (typeof mod === 'function') {
|
|
78
|
+
// Can't replace module.exports from here, but the WebSocket property
|
|
79
|
+
// is what @discordjs/ws uses via `import { WebSocket } from "ws"`
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
mod.__kimakiProxyPatched = true;
|
|
83
|
+
}
|
|
84
|
+
return mod;
|
|
85
|
+
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.1",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"tsx": "^4.20.5",
|
|
27
27
|
"undici": "^8.0.2",
|
|
28
28
|
"discord-digital-twin": "^0.1.0",
|
|
29
|
-
"opencode-deterministic-provider": "^0.0.1",
|
|
30
29
|
"opencode-cached-provider": "^0.0.1",
|
|
30
|
+
"opencode-deterministic-provider": "^0.0.1",
|
|
31
31
|
"db": "^0.0.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
"yaml": "^2.8.3",
|
|
65
65
|
"zod": "^4.3.6",
|
|
66
66
|
"zustand": "^5.0.11",
|
|
67
|
-
"errore": "^0.14.1",
|
|
68
67
|
"libsqlproxy": "^0.1.0",
|
|
68
|
+
"opencode-injection-guard": "^0.2.1",
|
|
69
69
|
"traforo": "^0.5.0",
|
|
70
|
-
"
|
|
70
|
+
"errore": "^0.14.1"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@snazzah/davey": "^0.1.10",
|
|
@@ -93,6 +93,9 @@ const CLAUDE_CODE_IDENTITY =
|
|
|
93
93
|
const OPENCODE_IDENTITY =
|
|
94
94
|
"You are OpenCode, the best coding agent on the planet.";
|
|
95
95
|
const ANTHROPIC_PROMPT_MARKER = "Skills provide specialized instructions";
|
|
96
|
+
// Subagent prompts don't contain OPENCODE_IDENTITY; opencode appends this
|
|
97
|
+
// line + an <env> block instead. We strip from here to </env> inclusive.
|
|
98
|
+
const SUBAGENT_MODEL_IDENTITY = "You are powered by the model named";
|
|
96
99
|
const CLAUDE_CODE_BETA = "claude-code-20250219";
|
|
97
100
|
const OAUTH_BETA = "oauth-2025-04-20";
|
|
98
101
|
const FINE_GRAINED_TOOL_STREAMING_BETA =
|
|
@@ -613,36 +616,77 @@ function sanitizeAnthropicSystemText(
|
|
|
613
616
|
onError?: (msg: string) => void,
|
|
614
617
|
) {
|
|
615
618
|
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
616
|
-
if (startIdx
|
|
619
|
+
if (startIdx !== -1) {
|
|
620
|
+
// Main session path: strip from OpenCode identity to the Anthropic prompt marker.
|
|
621
|
+
// Keep the marker aligned with the current OpenCode Anthropic prompt.
|
|
622
|
+
const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
|
|
623
|
+
if (endIdx === -1) {
|
|
624
|
+
onError?.(
|
|
625
|
+
"sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity",
|
|
626
|
+
);
|
|
627
|
+
return text;
|
|
628
|
+
}
|
|
629
|
+
return replaceBlockWithCompactEnv(text, startIdx, endIdx);
|
|
630
|
+
}
|
|
617
631
|
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
);
|
|
624
|
-
|
|
632
|
+
// Subagent path: opencode appends "You are powered by the model named ..."
|
|
633
|
+
// followed by an <env> block. Strip from that line through </env>.
|
|
634
|
+
const subagentIdx = text.indexOf(SUBAGENT_MODEL_IDENTITY);
|
|
635
|
+
if (subagentIdx !== -1) {
|
|
636
|
+
const envCloseTag = "</env>";
|
|
637
|
+
const envCloseIdx = text.indexOf(envCloseTag, subagentIdx);
|
|
638
|
+
if (envCloseIdx === -1) {
|
|
639
|
+
onError?.(
|
|
640
|
+
"sanitizeAnthropicSystemText: could not find </env> after subagent model identity",
|
|
641
|
+
);
|
|
642
|
+
return text;
|
|
643
|
+
}
|
|
644
|
+
const endIdx = envCloseIdx + envCloseTag.length;
|
|
645
|
+
// Skip trailing newline so the join is clean
|
|
646
|
+
const afterEnd =
|
|
647
|
+
text[endIdx] === "\n" ? endIdx + 1 : endIdx;
|
|
648
|
+
return replaceBlockWithCompactEnv(text, subagentIdx, afterEnd);
|
|
625
649
|
}
|
|
626
650
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
651
|
+
return text;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Extract cwd from the block being stripped and replace it with a compact
|
|
655
|
+
// <environment> tag. Shared by both main-session and subagent paths.
|
|
656
|
+
// Source: anomalyco/opencode packages/opencode/src/session/system.ts
|
|
657
|
+
// OpenCode's system prompt format (as of 2025):
|
|
658
|
+
// <env>
|
|
659
|
+
// Working directory: ${Instance.directory}
|
|
660
|
+
// Workspace root folder: ${Instance.worktree}
|
|
661
|
+
// Is directory a git repo: yes/no
|
|
662
|
+
// Platform: ${process.platform}
|
|
663
|
+
// Today's date: ${new Date().toDateString()}
|
|
664
|
+
// </env>
|
|
665
|
+
// Older format used <environment><cwd>/path</cwd></environment>.
|
|
666
|
+
// We try both patterns to stay compatible across opencode versions.
|
|
667
|
+
// We preserve the per-session directory instead of falling back to
|
|
668
|
+
// process.cwd() which is the opencode server's cwd and wrong for
|
|
669
|
+
// multi-session/worktree setups where each session has a different directory.
|
|
670
|
+
function replaceBlockWithCompactEnv(
|
|
671
|
+
text: string,
|
|
672
|
+
startIdx: number,
|
|
673
|
+
endIdx: number,
|
|
674
|
+
) {
|
|
632
675
|
const strippedBlock = text.slice(startIdx, endIdx);
|
|
633
|
-
const cwdMatch =
|
|
634
|
-
|
|
676
|
+
const cwdMatch =
|
|
677
|
+
strippedBlock.match(/Working directory:\s*(.+)/)?.[1]?.trim() ||
|
|
678
|
+
strippedBlock.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
|
|
679
|
+
const cwd = cwdMatch || process.cwd();
|
|
635
680
|
|
|
636
681
|
const envContext =
|
|
637
682
|
`\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
|
|
638
|
-
`Read, write, and edit files under
|
|
683
|
+
`Read, write, and edit files under ${cwd}.\n\n`;
|
|
639
684
|
|
|
640
|
-
|
|
685
|
+
return (
|
|
641
686
|
text.slice(0, startIdx) +
|
|
642
687
|
envContext +
|
|
643
|
-
text.slice(endIdx)
|
|
644
|
-
|
|
645
|
-
return result;
|
|
688
|
+
text.slice(endIdx)
|
|
689
|
+
);
|
|
646
690
|
}
|
|
647
691
|
|
|
648
692
|
function mapSystemTextPart(
|
|
@@ -475,6 +475,26 @@ Read this first.
|
|
|
475
475
|
`)
|
|
476
476
|
})
|
|
477
477
|
|
|
478
|
+
test('renders callout that was prefixed with ⬥ as plain text (regression)', () => {
|
|
479
|
+
// Before the fix, formatPart would add ⬥ prefix to callout lines,
|
|
480
|
+
// breaking the callout parser. Now formatPart skips the prefix for callouts.
|
|
481
|
+
const result = splitTablesFromMarkdown(`⬥ <callout accent="#ef4444">
|
|
482
|
+
## Top priority
|
|
483
|
+
- **Stripe dispute** deadline
|
|
484
|
+
</callout>`)
|
|
485
|
+
expect(result).toMatchInlineSnapshot(`
|
|
486
|
+
[
|
|
487
|
+
{
|
|
488
|
+
"text": "⬥ <callout accent="#ef4444">
|
|
489
|
+
## Top priority
|
|
490
|
+
- **Stripe dispute** deadline
|
|
491
|
+
</callout>",
|
|
492
|
+
"type": "text",
|
|
493
|
+
},
|
|
494
|
+
]
|
|
495
|
+
`)
|
|
496
|
+
})
|
|
497
|
+
|
|
478
498
|
test('falls back to plain text when a callout is not closed', () => {
|
|
479
499
|
const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
|
|
480
500
|
## Important
|
|
@@ -1,7 +1,52 @@
|
|
|
1
1
|
import { describe, test, expect } from 'vitest'
|
|
2
|
-
import { formatTodoList } from './message-formatting.js'
|
|
2
|
+
import { formatPart, formatTodoList } from './message-formatting.js'
|
|
3
3
|
import type { Part } from '@opencode-ai/sdk/v2'
|
|
4
4
|
|
|
5
|
+
describe('formatPart', () => {
|
|
6
|
+
test('callout text does not get ⬥ prefix', () => {
|
|
7
|
+
const part: Part = {
|
|
8
|
+
id: 'test',
|
|
9
|
+
type: 'text',
|
|
10
|
+
sessionID: 'ses_test',
|
|
11
|
+
messageID: 'msg_test',
|
|
12
|
+
text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
|
|
13
|
+
}
|
|
14
|
+
expect(formatPart(part)).toMatchInlineSnapshot(`
|
|
15
|
+
"
|
|
16
|
+
<callout accent="#ef4444">
|
|
17
|
+
## Top priority
|
|
18
|
+
- **Stripe dispute** deadline
|
|
19
|
+
</callout>"
|
|
20
|
+
`)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('regular text gets ⬥ prefix', () => {
|
|
24
|
+
const part: Part = {
|
|
25
|
+
id: 'test',
|
|
26
|
+
type: 'text',
|
|
27
|
+
sessionID: 'ses_test',
|
|
28
|
+
messageID: 'msg_test',
|
|
29
|
+
text: 'hello world',
|
|
30
|
+
}
|
|
31
|
+
expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('text starting with heading does not get ⬥ prefix', () => {
|
|
35
|
+
const part: Part = {
|
|
36
|
+
id: 'test',
|
|
37
|
+
type: 'text',
|
|
38
|
+
sessionID: 'ses_test',
|
|
39
|
+
messageID: 'msg_test',
|
|
40
|
+
text: '## Summary\nDone.',
|
|
41
|
+
}
|
|
42
|
+
expect(formatPart(part)).toMatchInlineSnapshot(`
|
|
43
|
+
"
|
|
44
|
+
## Summary
|
|
45
|
+
Done."
|
|
46
|
+
`)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
5
50
|
describe('formatTodoList', () => {
|
|
6
51
|
test('formats active todo with monospace numbers', () => {
|
|
7
52
|
const part: Part = {
|
|
@@ -412,7 +412,9 @@ export function formatPart(part: Part, prefix?: string): string {
|
|
|
412
412
|
const firstChar = text[0] || ''
|
|
413
413
|
const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
|
|
414
414
|
const startsWithMarkdown =
|
|
415
|
-
markdownStarters.includes(firstChar) ||
|
|
415
|
+
markdownStarters.includes(firstChar) ||
|
|
416
|
+
/^\d+\./.test(text) ||
|
|
417
|
+
/^<callout[\s>]/i.test(text)
|
|
416
418
|
if (startsWithMarkdown) {
|
|
417
419
|
return `\n${text}`
|
|
418
420
|
}
|