Merge #88 and fix agent team creation

This commit is contained in:
tjb-tech
2026-04-10 03:59:44 +00:00
parent a890f4eef5
commit 27abeae3eb
14 changed files with 538 additions and 25 deletions
+15 -4
View File
@@ -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"
},
+3 -1
View File
@@ -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",
+2 -1
View File
@@ -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('| ![alt](https://example.com/img.png) | 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) {
+1 -1
View File
@@ -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>;
+5
View File
@@ -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 — Im doing a quick memory squeeze, then Ill keep going."
if text.startswith(("🤔", "🧠", "", "🔎", "🪄", "🛠️", "🫧")):
return text
return f"🫧 {text}"
+4
View File
@@ -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
+6 -1
View File
@@ -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=(
+41 -1
View File
@@ -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"
+24
View File
@@ -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"))