diff --git a/frontend/app.js b/frontend/app.js index a7a5eb1a..5ed339d8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -288,10 +288,12 @@ class AppController { text: p.text || p.message, source: p.source_employee || 'system', }); - // Route to terminal — 1-on-1 or EA chat path - } else if (this._currentConvId === p.conv_id && this._ceoTerm && (this._currentConvType === 'oneonone' || this._currentConvType === 'ea_chat')) { + // Route to terminal — 1-on-1, EA chat, or product planning path + } else if (this._currentConvId === p.conv_id && this._ceoTerm && (this._currentConvType === 'oneonone' || this._currentConvType === 'ea_chat' || this._currentConvType === 'product')) { if (p.sender !== 'ceo' && p.text != null) { - const source = this._currentConvType === 'ea_chat' + // Product planning conversations are with the EA — label them + // the same as ea_chat. 1-on-1s show the employee's nickname. + const source = (this._currentConvType === 'ea_chat' || this._currentConvType === 'product') ? '玲珑阁 (EA)' : this._resolveEmployeeNickname(p.employee_id || this._currentConvEmployeeId || ''); this._ceoTerm.appendMessage({ diff --git a/package.json b/package.json index a4d035c7..69722a64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1mancompany/onemancompany", - "version": "0.7.85", + "version": "0.7.86", "description": "The AI Operating System for One-Person Companies", "bin": { "onemancompany": "bin/cli.js" diff --git a/pyproject.toml b/pyproject.toml index 9c847545..ec5f760c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onemancompany" -version = "0.7.85" +version = "0.7.86" description = "A one-man company simulation with pixel art visualization and LangChain AI agents" requires-python = ">=3.12" dependencies = [ diff --git a/tests/frontend/test_conversation_routing.js b/tests/frontend/test_conversation_routing.js new file mode 100644 index 00000000..085ce8fe --- /dev/null +++ b/tests/frontend/test_conversation_routing.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/* Regression test for the frontend's conversation_message router. + * + * Bug: PRODUCT planning conversations are opened in the CEO terminal + * (frontend/app.js:_openProductPlanningConversation sets + * _currentConvType = 'product'), but the conversation_message handler's + * terminal-routing branch only matched _currentConvType === 'oneonone' || + * 'ea_chat'. EA replies for product planning thus arrived via WebSocket + * but were never rendered — they only showed up in server logs because + * debug logging captured the LLM output text. + * + * Two assertions: + * 1. Source-level: the routing filter in app.js includes 'product'. + * 2. Behavioral: a small reimplementation of the routing predicate + * returns true for {convType: 'product', conv_id matches}. + */ + +const fs = require("fs"); +const path = require("path"); + +const appJsPath = path.resolve(__dirname, "..", "..", "frontend", "app.js"); +const appJsSrc = fs.readFileSync(appJsPath, "utf-8"); + +let failures = 0; +function assert(cond, msg) { + if (cond) console.log(` ok ${msg}`); + else { + failures += 1; + console.log(` FAIL ${msg}`); + } +} + +// ── Source-level invariant ──────────────────────────────────────────────── +// The terminal-routing branch of the conversation_message handler must +// include 'product' alongside 'oneonone' and 'ea_chat'. We grep the +// production source to ensure the fix isn't accidentally reverted. +const filterPattern = + /this\._currentConvType\s*===\s*'oneonone'\s*\|\|\s*this\._currentConvType\s*===\s*'ea_chat'\s*\|\|\s*this\._currentConvType\s*===\s*'product'/; +assert( + filterPattern.test(appJsSrc), + "conversation_message router includes 'product' in the terminal-routing filter", +); + +// ── Behavioral mirror ───────────────────────────────────────────────────── +// Re-implement the predicate so a unit test would have caught the bug +// before it shipped. If the production filter ever drifts, the +// source-level assertion above will fail; if our predicate drifts, the +// behavioral cases below will fail. +function shouldRouteToTerminal(currentConvId, currentConvType, messageConvId) { + if (currentConvId !== messageConvId) return false; + return ( + currentConvType === "oneonone" || + currentConvType === "ea_chat" || + currentConvType === "product" + ); +} + +assert( + shouldRouteToTerminal("c-1", "product", "c-1") === true, + "product planning replies route to the terminal when the conv is open", +); +assert( + shouldRouteToTerminal("c-1", "oneonone", "c-1") === true, + "1-on-1 replies still route to the terminal", +); +assert( + shouldRouteToTerminal("c-1", "ea_chat", "c-1") === true, + "EA chat replies still route to the terminal", +); +assert( + shouldRouteToTerminal("c-1", "product", "c-2") === false, + "messages for a different conv_id do NOT route to the terminal", +); +assert( + shouldRouteToTerminal("c-1", "ceo_inbox", "c-1") === true ? false : true, + "ceo_inbox conversations do NOT route to terminal (they use chatPanel)", +); + +if (failures) { + console.log(`\n${failures} failed`); + process.exit(1); +} +console.log("\nall tests passed"); diff --git a/tests/integration/test_frontend_conversation_routing.py b/tests/integration/test_frontend_conversation_routing.py new file mode 100644 index 00000000..392ea5c1 --- /dev/null +++ b/tests/integration/test_frontend_conversation_routing.py @@ -0,0 +1,32 @@ +"""Pytest wrapper for the node-based frontend conversation-routing test. + +Regression guard: PRODUCT planning conversations were not routed to the +CEO terminal because the conversation_message handler only matched +'oneonone' / 'ea_chat'. EA replies arrived via WebSocket but never +rendered. The accompanying node test asserts both the source-level +filter and a behavioral mirror of the routing predicate. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +TEST_SCRIPT = REPO_ROOT / "tests" / "frontend" / "test_conversation_routing.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") +def test_frontend_conversation_routing() -> None: + result = subprocess.run( + ["node", str(TEST_SCRIPT)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"frontend routing test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + )