Merge #88 and fix agent team creation
This commit is contained in:
Generated
+15
-4
@@ -8,7 +8,9 @@
|
||||
"dependencies": {
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^18.3.1"
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.3.1",
|
||||
"string-width": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
@@ -495,7 +497,6 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -787,7 +788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
|
||||
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -893,6 +893,18 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
||||
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
@@ -931,7 +943,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"dependencies": {
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^18.3.1"
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.3.1",
|
||||
"string-width": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
|
||||
@@ -437,12 +437,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
setInput={setInput}
|
||||
onSubmit={onSubmit}
|
||||
toolName={session.busy ? currentToolName : undefined}
|
||||
statusLabel={session.busy ? (currentToolName ? `Running ${currentToolName}...` : 'Running...') : undefined}
|
||||
suppressSubmit={showPicker}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard hints (only after backend is ready) */}
|
||||
{session.ready && !session.modal && !session.busy && !selectModal ? (
|
||||
{session.ready && !session.modal && !selectModal ? (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
<Text color={theme.colors.primary}>enter</Text> send{' '}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {TranscriptItem} from '../types.js';
|
||||
import {MarkdownText} from './MarkdownText.js';
|
||||
import {ToolCallDisplay} from './ToolCallDisplay.js';
|
||||
import {WelcomeBanner} from './WelcomeBanner.js';
|
||||
|
||||
@@ -28,9 +28,13 @@ export function ConversationView({
|
||||
))}
|
||||
|
||||
{assistantBuffer ? (
|
||||
<Box flexDirection="row" marginTop={0}>
|
||||
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
|
||||
<Text>{assistantBuffer}</Text>
|
||||
<Box marginTop={1} marginBottom={0} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<MarkdownText content={assistantBuffer} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -54,8 +58,10 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
|
||||
<Box marginTop={1} marginBottom={0} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
|
||||
<Text>{item.text}</Text>
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<MarkdownText content={item.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -73,6 +79,13 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text color={theme.colors.info}>{item.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'log':
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import React from 'react';
|
||||
import {render} from 'ink';
|
||||
|
||||
import {ThemeProvider} from '../theme/ThemeContext.js';
|
||||
import {MarkdownText} from './MarkdownText.js';
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
type InkTestStdout = PassThrough & {
|
||||
isTTY: boolean;
|
||||
columns: number;
|
||||
rows: number;
|
||||
cursorTo: () => boolean;
|
||||
clearLine: () => boolean;
|
||||
moveCursor: () => boolean;
|
||||
};
|
||||
|
||||
function createTestStdout(): InkTestStdout {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
cursorTo: () => true,
|
||||
clearLine: () => true,
|
||||
moveCursor: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOutputToStabilize(getOutput: () => string): Promise<string> {
|
||||
let previous = '';
|
||||
let sawOutput = false;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await nextLoopTurn();
|
||||
const current = getOutput();
|
||||
sawOutput ||= current.length > 0;
|
||||
if (sawOutput && current === previous) {
|
||||
return current;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
}
|
||||
|
||||
throw new Error(`Ink output did not stabilize: ${JSON.stringify(previous)}`);
|
||||
}
|
||||
|
||||
async function renderMarkdownLines(content: string): Promise<string[]> {
|
||||
const stdout = createTestStdout();
|
||||
|
||||
let output = '';
|
||||
stdout.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
const instance = render(
|
||||
<ThemeProvider initialTheme="default">
|
||||
<MarkdownText content={content} />
|
||||
</ThemeProvider>,
|
||||
{stdout: stdout as unknown as NodeJS.WriteStream, debug: true, patchConsole: false},
|
||||
);
|
||||
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
const stableOutput = await waitForOutputToStabilize(() => output);
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
await waitForOutputToStabilize(() => output);
|
||||
instance.cleanup();
|
||||
|
||||
return stripAnsi(stableOutput)
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function renderTableLines(content: string): Promise<string[]> {
|
||||
return (await renderMarkdownLines(content))
|
||||
.filter((line) => /[┌├│└]/.test(line))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
test('keeps table borders aligned when cells contain inline markdown', async () => {
|
||||
const lines = await renderTableLines('| `aa` | bb |\n|------|----|\n| c | **ddd** |');
|
||||
|
||||
assert.equal(lines.length, 5);
|
||||
|
||||
const widths = lines.map((line) => [...line].length);
|
||||
assert.ok(
|
||||
widths.every((width) => width === widths[0]),
|
||||
`Expected table lines to share a width, got ${JSON.stringify(
|
||||
lines.map((line, index) => ({line, width: widths[index]})),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('renders unknown inline table tokens using the visible token text fallback', async () => {
|
||||
const lines = await renderTableLines('|  | ok |\n|---|---|\n| x | y |');
|
||||
|
||||
assert.equal(lines.length, 5);
|
||||
assert.match(lines[1] ?? '', /\balt\b/);
|
||||
assert.doesNotMatch(lines[1] ?? '', /!\[alt\]/);
|
||||
|
||||
const widths = lines.map((line) => [...line].length);
|
||||
assert.ok(
|
||||
widths.every((width) => width === widths[0]),
|
||||
`Expected fallback-token table lines to share a width, got ${JSON.stringify(
|
||||
lines.map((line, index) => ({line, width: widths[index]})),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves nested markdown structure inside blockquotes', async () => {
|
||||
const lines = await renderMarkdownLines('> - first\n> - second');
|
||||
|
||||
assert.ok(lines.some((line) => line.includes('• first')), `Expected blockquote output to include a rendered bullet: ${JSON.stringify(lines)}`);
|
||||
assert.ok(lines.some((line) => line.includes('• second')), `Expected blockquote output to include the second rendered bullet: ${JSON.stringify(lines)}`);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
import {lexer, type Token, type Tokens} from 'marked';
|
||||
import stringWidth from 'string-width';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {ThemeConfig} from '../theme/builtinThemes.js';
|
||||
|
||||
function getInlineFallbackText(token: Token): string {
|
||||
if ('text' in token && typeof token.text === 'string') {
|
||||
return token.text;
|
||||
}
|
||||
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function getInlineDisplayText(tokens: Token[] | undefined): string {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return tokens.map((token) => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
const t = token as Tokens.Text;
|
||||
return t.tokens && t.tokens.length > 0 ? getInlineDisplayText(t.tokens) : t.text;
|
||||
}
|
||||
case 'strong':
|
||||
case 'em':
|
||||
case 'del':
|
||||
return getInlineDisplayText((token as Tokens.Strong | Tokens.Em | Tokens.Del).tokens);
|
||||
case 'codespan':
|
||||
return (token as Tokens.Codespan).text;
|
||||
case 'link': {
|
||||
const l = token as Tokens.Link;
|
||||
return l.text || l.href;
|
||||
}
|
||||
case 'image': {
|
||||
const image = token as Tokens.Image;
|
||||
return image.text || image.href;
|
||||
}
|
||||
case 'br':
|
||||
return '\n';
|
||||
case 'escape':
|
||||
return (token as Tokens.Escape).text;
|
||||
default:
|
||||
return getInlineFallbackText(token);
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getTableCellDisplayText(cell: Tokens.TableCell): string {
|
||||
const displayText = getInlineDisplayText(cell.tokens);
|
||||
return displayText.length > 0 ? displayText : cell.text;
|
||||
}
|
||||
|
||||
function renderInline(tokens: Token[] | undefined, theme: ThemeConfig): React.ReactNode {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return tokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
const t = token as Tokens.Text;
|
||||
if (t.tokens && t.tokens.length > 0) {
|
||||
return <React.Fragment key={i}>{renderInline(t.tokens, theme)}</React.Fragment>;
|
||||
}
|
||||
return <Text key={i}>{t.text}</Text>;
|
||||
}
|
||||
case 'strong': {
|
||||
const s = token as Tokens.Strong;
|
||||
return (
|
||||
<Text key={i} bold>
|
||||
{renderInline(s.tokens, theme)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'em': {
|
||||
const e = token as Tokens.Em;
|
||||
return (
|
||||
<Text key={i} italic>
|
||||
{renderInline(e.tokens, theme)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'del': {
|
||||
const d = token as Tokens.Del;
|
||||
return (
|
||||
<Text key={i} strikethrough>
|
||||
{renderInline(d.tokens, theme)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'codespan': {
|
||||
const c = token as Tokens.Codespan;
|
||||
return (
|
||||
<Text key={i} color={theme.colors.accent}>
|
||||
{c.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
const l = token as Tokens.Link;
|
||||
const label = l.text || l.href;
|
||||
return (
|
||||
<Text key={i} color={theme.colors.info}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'image': {
|
||||
const image = token as Tokens.Image;
|
||||
return <Text key={i}>{image.text || image.href}</Text>;
|
||||
}
|
||||
case 'br':
|
||||
return <Text key={i}>{'\n'}</Text>;
|
||||
case 'escape': {
|
||||
const es = token as Tokens.Escape;
|
||||
return <Text key={i}>{es.text}</Text>;
|
||||
}
|
||||
default:
|
||||
return <Text key={i}>{getInlineFallbackText(token)}</Text>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlocks(tokens: Token[] | undefined, theme: ThemeConfig): React.ReactNode {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokens.map((token, i) => <MarkdownBlock key={i} token={token} theme={theme} />);
|
||||
}
|
||||
|
||||
function MarkdownBlock({token, theme}: {token: Token; theme: ThemeConfig}): React.JSX.Element | null {
|
||||
switch (token.type) {
|
||||
case 'heading': {
|
||||
const h = token as Tokens.Heading;
|
||||
const headingColors: string[] = [
|
||||
theme.colors.primary,
|
||||
theme.colors.secondary,
|
||||
theme.colors.accent,
|
||||
theme.colors.info,
|
||||
theme.colors.muted,
|
||||
theme.colors.muted,
|
||||
];
|
||||
const color = headingColors[h.depth - 1] ?? theme.colors.primary;
|
||||
const isMajor = h.depth <= 2;
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={color} bold={isMajor} underline={h.depth === 1}>
|
||||
{renderInline(h.tokens, theme)}
|
||||
</Text>
|
||||
{h.depth === 1 ? <Text color={color} dimColor>{'━'.repeat(32)}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
const p = token as Tokens.Paragraph;
|
||||
return (
|
||||
<Box marginTop={0} flexWrap="wrap">
|
||||
<Text>{renderInline(p.tokens, theme)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'code': {
|
||||
const c = token as Tokens.Code;
|
||||
const lines = c.text.split('\n');
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2} borderStyle="round" paddingX={1} borderColor={theme.colors.muted}>
|
||||
{c.lang ? <Text dimColor>{c.lang}</Text> : null}
|
||||
{lines.map((line, i) => (
|
||||
<Text key={i} color={theme.colors.accent}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'blockquote': {
|
||||
const bq = token as Tokens.Blockquote;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginLeft={0}>
|
||||
{bq.tokens.map((t, i) => (
|
||||
<Box key={i} flexDirection="row">
|
||||
<Text color={theme.colors.muted}>{'│ '}</Text>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{renderBlocks([t], theme)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const l = token as Tokens.List;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginLeft={2}>
|
||||
{l.items.map((item, i) => {
|
||||
const inlineTokens: Token[] = item.tokens.flatMap((t) =>
|
||||
'tokens' in t && t.tokens ? (t.tokens as Token[]) : [],
|
||||
);
|
||||
const bullet = l.ordered ? `${(Number(l.start) || 1) + i}. ` : '• ';
|
||||
return (
|
||||
<Box key={i} flexDirection="row">
|
||||
<Text color={theme.colors.primary}>{bullet}</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Text>{inlineTokens.length > 0 ? renderInline(inlineTokens, theme) : item.text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'hr':
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{'─'.repeat(48)}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'space':
|
||||
return null;
|
||||
|
||||
case 'table': {
|
||||
const t = token as Tokens.Table;
|
||||
const headerTexts = t.header.map(getTableCellDisplayText);
|
||||
const rowTexts = t.rows.map((row) => row.map(getTableCellDisplayText));
|
||||
const colCount = t.header.length;
|
||||
const colWidths: number[] = headerTexts.map((cellText) => stringWidth(cellText));
|
||||
for (const row of rowTexts) {
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
colWidths[c] = Math.max(colWidths[c] ?? 0, stringWidth(row[c] ?? ''));
|
||||
}
|
||||
}
|
||||
const trailing = (cellText: string, c: number): string =>
|
||||
' '.repeat(Math.max(0, (colWidths[c] ?? 0) - stringWidth(cellText)));
|
||||
const top = '┌' + colWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐';
|
||||
const sep = '├' + colWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤';
|
||||
const bot = '└' + colWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘';
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text>{top}</Text>
|
||||
<Text>
|
||||
{'│' + headerTexts.map((cell, c) => ` ${cell}${trailing(cell, c)} `).join('│') + '│'}
|
||||
</Text>
|
||||
<Text>{sep}</Text>
|
||||
{rowTexts.map((row, r) => (
|
||||
<Text key={r}>
|
||||
{'│' + row.map((cell, c) => ` ${cell}${trailing(cell, c)} `).join('│') + '│'}
|
||||
</Text>
|
||||
))}
|
||||
<Text>{bot}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
if ('text' in token && typeof token.text === 'string') {
|
||||
return (
|
||||
<Box>
|
||||
<Text>{token.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownText({content}: {content: string}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const tokens = React.useMemo(() => lexer(content ?? ''), [content]);
|
||||
return <Box flexDirection="column">{renderBlocks(tokens, theme)}</Box>;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function PromptInput({
|
||||
onSubmit,
|
||||
toolName,
|
||||
suppressSubmit,
|
||||
statusLabel,
|
||||
}: {
|
||||
busy: boolean;
|
||||
input: string;
|
||||
@@ -21,21 +22,21 @@ export function PromptInput({
|
||||
onSubmit: (value: string) => void;
|
||||
toolName?: string;
|
||||
suppressSubmit?: boolean;
|
||||
statusLabel?: string;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
|
||||
if (busy) {
|
||||
return (
|
||||
<Box>
|
||||
<Spinner label={toolName ? `Running ${toolName}...` : undefined} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.colors.primary} bold>{'> '}</Text>
|
||||
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit ? noop : onSubmit} />
|
||||
<Box flexDirection="column">
|
||||
{busy ? (
|
||||
<Box marginBottom={0}>
|
||||
<Spinner label={statusLabel ?? (toolName ? `Running ${toolName}...` : 'Running...')} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text color={theme.colors.primary} bold>{busy ? '… ' : '> '}</Text>
|
||||
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit || busy ? noop : onSubmit} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,14 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
setTranscript((items) => [...items, event.item as TranscriptItem]);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'status') {
|
||||
const message = event.message?.trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
setTranscript((items) => [...items, {role: 'status', text: message}]);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'assistant_delta') {
|
||||
const delta = event.message ?? '';
|
||||
if (!delta) {
|
||||
|
||||
@@ -4,7 +4,7 @@ export type FrontendConfig = {
|
||||
};
|
||||
|
||||
export type TranscriptItem = {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'log';
|
||||
role: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'log' | 'status';
|
||||
text: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
|
||||
@@ -517,6 +517,11 @@ def _format_channel_progress(
|
||||
return f"🛠️ {text}"
|
||||
return text if text.startswith("🛠️ ") else f"🛠️ {text}"
|
||||
if kind == "status":
|
||||
normalized = text.strip()
|
||||
if normalized == "Auto-compacting conversation memory to keep things fast and focused.":
|
||||
if prefers_chinese:
|
||||
return "🧠 聊天有点长啦,我先帮你悄悄压缩一下记忆,马上继续~"
|
||||
return "🧠 This chat is getting long — I’m doing a quick memory squeeze, then I’ll keep going."
|
||||
if text.startswith(("🤔", "🧠", "✨", "🔎", "🪄", "🛠️", "🫧")):
|
||||
return text
|
||||
return f"🫧 {text}"
|
||||
|
||||
@@ -27,6 +27,8 @@ from openharness.engine.stream_events import (
|
||||
ToolExecutionCompleted,
|
||||
ToolExecutionStarted,
|
||||
)
|
||||
|
||||
AUTO_COMPACT_STATUS_MESSAGE = "Auto-compacting conversation memory to keep things fast and focused."
|
||||
from openharness.hooks import HookEvent, HookExecutor
|
||||
from openharness.permissions.checker import PermissionChecker
|
||||
from openharness.tools.base import ToolExecutionContext
|
||||
@@ -95,6 +97,8 @@ async def run_query(
|
||||
system_prompt=context.system_prompt,
|
||||
state=compact_state,
|
||||
)
|
||||
if was_compacted:
|
||||
yield StatusEvent(message=AUTO_COMPACT_STATUS_MESSAGE), None
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
final_message: ConversationMessage | None = None
|
||||
|
||||
@@ -84,7 +84,12 @@ class AgentTool(BaseTool):
|
||||
return ToolResult(output=result.error or "Failed to spawn agent", is_error=True)
|
||||
|
||||
if arguments.team:
|
||||
get_team_registry().add_agent(arguments.team, result.task_id)
|
||||
registry = get_team_registry()
|
||||
try:
|
||||
registry.add_agent(arguments.team, result.task_id)
|
||||
except ValueError:
|
||||
registry.create_team(arguments.team)
|
||||
registry.add_agent(arguments.team, result.task_id)
|
||||
|
||||
return ToolResult(
|
||||
output=(
|
||||
|
||||
@@ -12,7 +12,7 @@ from openharness.channels.bus.events import InboundMessage
|
||||
from openharness.channels.bus.queue import MessageBus
|
||||
from openharness.commands import CommandResult
|
||||
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock
|
||||
from openharness.engine.stream_events import AssistantTextDelta, ToolExecutionStarted
|
||||
from openharness.engine.stream_events import AssistantTextDelta, StatusEvent, ToolExecutionStarted
|
||||
|
||||
from ohmo.gateway.bridge import OhmoGatewayBridge, _format_gateway_error
|
||||
from ohmo.gateway.models import GatewayState
|
||||
@@ -184,6 +184,46 @@ async def test_runtime_pool_stream_message_emits_progress_and_tool_hint(tmp_path
|
||||
assert updates[-1].text == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pool_stream_message_formats_auto_compact_status_for_feishu(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
initialize_workspace(workspace)
|
||||
|
||||
async def fake_build_runtime(**kwargs):
|
||||
class FakeEngine:
|
||||
messages = []
|
||||
total_usage = UsageSnapshot()
|
||||
|
||||
def set_system_prompt(self, prompt):
|
||||
return None
|
||||
|
||||
async def submit_message(self, content):
|
||||
yield StatusEvent(message="Auto-compacting conversation memory to keep things fast and focused.")
|
||||
yield AssistantTextDelta(text="done")
|
||||
|
||||
return SimpleNamespace(
|
||||
engine=FakeEngine(),
|
||||
session_id="sess123",
|
||||
current_settings=lambda: SimpleNamespace(model="gpt-5.4"),
|
||||
commands=SimpleNamespace(lookup=lambda raw: None),
|
||||
)
|
||||
|
||||
async def fake_start_runtime(bundle):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime)
|
||||
monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime)
|
||||
|
||||
pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="codex")
|
||||
message = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="继续")
|
||||
updates = [u async for u in pool.stream_message(message, "feishu:c1")]
|
||||
|
||||
assert updates[1].kind == "progress"
|
||||
assert updates[1].text == "🧠 聊天有点长啦,我先帮你悄悄压缩一下记忆,马上继续~"
|
||||
assert updates[-1].kind == "final"
|
||||
assert updates[-1].text == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pool_stream_message_uses_english_progress_for_english_input(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openharness.coordinator.coordinator_mode import get_team_registry
|
||||
from openharness.tasks import get_task_manager
|
||||
from openharness.tools.agent_tool import AgentTool, AgentToolInput
|
||||
from openharness.tools.base import ToolExecutionContext
|
||||
@@ -182,6 +183,29 @@ async def test_send_message_swarm_path_uses_subprocess_backend(
|
||||
assert agent_id_arg == "worker@default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_tool_creates_missing_team_when_team_argument_is_provided(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
get_team_registry()._teams.clear()
|
||||
context = ToolExecutionContext(cwd=tmp_path)
|
||||
|
||||
result = await AgentTool().execute(
|
||||
AgentToolInput(
|
||||
description="team auto-create regression",
|
||||
prompt="ready",
|
||||
subagent_type="test-worker-team",
|
||||
team="design-qa-loop",
|
||||
command="python -u -c \"import sys; print(sys.stdin.readline().strip())\"",
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
assert result.is_error is False
|
||||
teams = {team.name: team for team in get_team_registry().list_teams()}
|
||||
assert "design-qa-loop" in teams
|
||||
assert len(teams["design-qa-loop"].agents) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_tool_supports_remote_and_teammate_modes(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
|
||||
Reference in New Issue
Block a user