Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 121 additions & 7 deletions apigateway/internal/fetcher/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,18 +437,79 @@ func shouldLaunchSearchWorkflowFromChat(words map[string]bool) bool {
return false
}

// isRussian reports whether the message contains Cyrillic letters. The chat
// answers in the same language the user wrote in, so a greeting like «привет»
// is met with a Russian reply instead of the previous English-only canned text
// (issue #70: the chat ignored casual/Russian messages and felt dead).
func isRussian(message string) bool {
for _, r := range message {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}

// buildBossChatReply produces a conversational reply for messages that are not
// workflow/search requests. The chat must behave like a normal assistant — a
// plain «привет» or "hello" should get a friendly answer rather than silence or
// a generic English line (issue #70). Replies are returned in the user's
// language (Russian when the message contains Cyrillic, English otherwise).
func buildBossChatReply(message string) string {
ru := isRussian(message)
words := normalizedWords(message)
if hasAnyWord(words, []string{"hello", "hi", "hey"}) {
return "Hi, I'm here."

// Greetings — hello / hi / привет / здравствуйте / добрый день …
if hasAnyWord(words, []string{
"hello", "hi", "hey", "yo", "hiya", "howdy",
"привет", "приветик", "прив", "здравствуй", "здравствуйте", "здарова",
"хай", "ку", "салют", "добрый", "доброе",
}) {
if ru {
return "Привет! Я Octra. Можем просто пообщаться, а можно описать, что нужно собрать или найти, — и я возьмусь за задачу."
}
return "Hi! I'm Octra. We can just chat, or you can describe what to build or look up and I'll get to work."
}
if hasAnyWord(words, []string{"thanks", "thank"}) {
return "You're welcome."

// How are you — как дела / как ты / how are you …
if hasAnyWord(words, []string{"дела", "поживаешь"}) ||
(words["how"] && words["you"] && (words["are"] || words["doing"])) {
if ru {
return "У меня всё отлично, спасибо! Чем помочь — пообщаться, поискать что-то в интернете или собрать проект?"
}
return "I'm doing great, thanks! Want to chat, research something, or have me build a project?"
}

// Thanks — thanks / thank you / спасибо / благодарю …
if hasAnyWord(words, []string{"thanks", "thank", "thx", "спасибо", "благодарю", "спс", "пасиб"}) {
if ru {
return "Всегда пожалуйста! Если понадобится что-то ещё — просто напишите."
}
return "You're welcome! Let me know if there's anything else."
}

// Farewell — bye / goodbye / пока / до свидания …
if hasAnyWord(words, []string{"bye", "goodbye", "cya", "пока", "свидания", "увидимся", "прощай"}) {
if ru {
return "Пока! Возвращайтесь, когда понадобится помощь."
}
return "Bye! Come back whenever you need a hand."
}

// Identity / capabilities / help — who are you / что ты умеешь / помоги …
if hasAnyWord(words, []string{"help", "помощь", "помоги", "умеешь", "можешь"}) ||
(words["who"] && words["you"]) || (words["what"] && (words["can"] || words["do"]) && words["you"]) ||
(words["кто"] && words["ты"]) || (words["что"] && words["ты"]) {
if ru {
return "Я Octra — фабрика ИИ-агентов. Могу просто общаться, искать информацию в интернете или собрать проект целиком. Например: «создай php сервер» или «найди документацию по httpx»."
}
return "I'm Octra, an AI agent factory. I can chat, research the web, or build a whole project for you. Try: \"create a php server\" or \"find the httpx docs\"."
}
if hasAnyWord(words, []string{"help", "workflow", "build", "create"}) {
return "I can answer here, or start the workflow when you describe code you want built or changed."

if ru {
return "Понял вас. Опишите задачу — и я возьмусь за работу, либо продолжим общение."
}
return "I understand. Send the next detail when you are ready."
return "Got it. Describe a task and I'll get to work, or we can keep chatting."
}

func normalizedWords(message string) map[string]bool {
Expand Down Expand Up @@ -589,11 +650,64 @@ func processTaskStreamWS(conn *websocket.Conn, taskReq requests.CreateTaskReques

if update.Status == "success" || update.Status == "error" {
wsHub.Broadcast(streamID, wsUpdate)
// When the workflow finishes successfully the boss reports back to the
// user in the chat ("отчитаться") so a completed task never ends in
// silence (issue #70). Errors keep their existing red status banner.
if update.Status == "success" {
sendCompletionReport(conn, streamID, taskReq, update.Data)
}
return
}
}
}

// sendCompletionReport posts a short boss chat message summarizing a finished
// task. It folds in the boss's own answer (chatSummary, used by
// research/document tasks) and a link to the result when one is available, and
// is written in the language of the original request (issue #70).
func sendCompletionReport(conn *websocket.Conn, taskID string, taskReq requests.CreateTaskRequest, data map[string]string) {
writeBossChatMessage(conn, taskID, buildCompletionReport(taskReq, data), false)
}

// buildCompletionReport composes the boss's completion message in the language
// of the original request, folding in the boss's own answer (chatSummary) and a
// result link when available.
func buildCompletionReport(taskReq requests.CreateTaskRequest, data map[string]string) string {
ru := isRussian(taskReq.Title + " " + taskReq.Description)

link := data["pullRequestUrl"]
if link == "" {
link = data["repoUrl"]
}
if link == "" {
link = data["zipUrl"]
}

var b strings.Builder
if ru {
b.WriteString("✅ Готово! Я завершил задачу")
} else {
b.WriteString("✅ Done! I've finished the task")
}
if title := strings.TrimSpace(taskReq.Title); title != "" {
b.WriteString(" «" + title + "»")
}
b.WriteString(".")

if summary := strings.TrimSpace(data["chatSummary"]); summary != "" {
b.WriteString("\n\n" + summary)
}
if link != "" {
if ru {
b.WriteString("\n\nРезультат: " + link)
} else {
b.WriteString("\n\nResult: " + link)
}
}

return b.String()
}

// handleTaskReconnectWS handles WebSocket reconnection to an existing task
func handleTaskReconnectWS(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
Expand Down
128 changes: 128 additions & 0 deletions apigateway/internal/fetcher/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package fetcher
import (
"strings"
"testing"

"apigateway/pkg/requests"
)

// TestNewStreamIDIsolatesConcurrentStreams guards the fix for cross-tab history
Expand Down Expand Up @@ -111,3 +113,129 @@ func TestShouldLaunchWorkflowFromChatSearchRequests(t *testing.T) {
})
}
}

// TestBuildBossChatReplyConversational guards issue #70: casual messages that
// are not workflow/search requests must get a real conversational answer, in
// the user's language, instead of silence or a generic English line. The key
// regression is «привет» — it used to fall through to an English fallback, so
// the chat felt dead.
func TestBuildBossChatReplyConversational(t *testing.T) {
tests := []struct {
name string
message string
wantSubstrings []string // reply must contain at least one of these
wantRussian bool // reply must be in Russian (Cyrillic) ...
wantNotRussian bool // ... or must NOT contain Cyrillic
}{
{
name: "Russian greeting replies in Russian",
message: "привет",
wantSubstrings: []string{"Привет"},
wantRussian: true,
},
{
name: "English greeting replies in English",
message: "hello",
wantSubstrings: []string{"Hi"},
wantNotRussian: true,
},
{
name: "Russian thanks",
message: "спасибо",
wantSubstrings: []string{"пожалуйста"},
wantRussian: true,
},
{
name: "English thanks",
message: "thanks!",
wantSubstrings: []string{"welcome"},
wantNotRussian: true,
},
{
name: "Russian capability question",
message: "что ты умеешь?",
wantSubstrings: []string{"Octra"},
wantRussian: true,
},
{
name: "English who are you",
message: "who are you?",
wantSubstrings: []string{"Octra"},
wantNotRussian: true,
},
{
name: "Russian fallback stays Russian",
message: "ну ладно",
wantSubstrings: []string{"задач"},
wantRussian: true,
},
{
name: "English fallback stays English",
message: "ok then",
wantSubstrings: []string{"task"},
wantNotRussian: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reply := buildBossChatReply(tt.message)
if reply == "" {
t.Fatalf("buildBossChatReply(%q) returned empty reply", tt.message)
}

matched := false
for _, sub := range tt.wantSubstrings {
if strings.Contains(reply, sub) {
matched = true
break
}
}
if !matched {
t.Fatalf("buildBossChatReply(%q) = %q, want it to contain one of %v", tt.message, reply, tt.wantSubstrings)
}

if tt.wantRussian && !isRussian(reply) {
t.Fatalf("buildBossChatReply(%q) = %q, want a Russian reply", tt.message, reply)
}
if tt.wantNotRussian && isRussian(reply) {
t.Fatalf("buildBossChatReply(%q) = %q, want an English reply", tt.message, reply)
}
})
}
}

// TestSendCompletionReportContent guards the issue #70 requirement that the boss
// reports back in chat when a task finishes — including the boss's own answer
// (chatSummary), a result link, and the request's language.
func TestSendCompletionReportContent(t *testing.T) {
t.Run("Russian task with PR link and summary", func(t *testing.T) {
report := buildCompletionReport(
requests.CreateTaskRequest{Title: "Мини прокси на Go"},
map[string]string{
"chatSummary": "Сделал прокси с маршрутизацией.",
"pullRequestUrl": "https://github.com/o/r/pull/7",
},
)
for _, want := range []string{"Готово", "Мини прокси на Go", "Сделал прокси", "https://github.com/o/r/pull/7", "Результат"} {
if !strings.Contains(report, want) {
t.Fatalf("report = %q, want it to contain %q", report, want)
}
}
})

t.Run("English task falls back to repo link", func(t *testing.T) {
report := buildCompletionReport(
requests.CreateTaskRequest{Title: "PHP server"},
map[string]string{"repoUrl": "https://github.com/o/r"},
)
for _, want := range []string{"Done", "PHP server", "https://github.com/o/r", "Result"} {
if !strings.Contains(report, want) {
t.Fatalf("report = %q, want it to contain %q", report, want)
}
}
if isRussian(report) {
t.Fatalf("report = %q, want an English report", report)
}
})
}
3 changes: 2 additions & 1 deletion frontend/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"test:app-render-guards": "node scripts/check-app-render-guards.mjs",
"test:onboarding-tour": "node scripts/check-onboarding-tour.mjs",
"test:token-statistics": "node scripts/check-token-statistics.mjs",
"test": "npm run test:settings && npm run test:task-store && npm run test:task-payload && npm run test:chat-history && npm run test:solution-viewer && npm run test:search-steps && npm run test:search-button && npm run test:sidebar-auth && npm run test:sidebar-dock && npm run test:landing && npm run test:custom-providers && npm run test:chat-input && npm run test:bottom-input-attachments && npm run test:file-tree && npm run test:workspace && npm run test:mobile-header && npm run test:profile-avatar && npm run test:pull-request-summary && npm run test:model-selector-toggle && npm run test:desktop-integration && npm run test:app-render-guards && npm run test:onboarding-tour && npm run test:token-statistics"
"test:chat-typewriter": "node scripts/check-chat-typewriter.mjs",
"test": "npm run test:settings && npm run test:task-store && npm run test:task-payload && npm run test:chat-history && npm run test:solution-viewer && npm run test:search-steps && npm run test:search-button && npm run test:sidebar-auth && npm run test:sidebar-dock && npm run test:landing && npm run test:custom-providers && npm run test:chat-input && npm run test:bottom-input-attachments && npm run test:file-tree && npm run test:workspace && npm run test:mobile-header && npm run test:profile-avatar && npm run test:pull-request-summary && npm run test:model-selector-toggle && npm run test:desktop-integration && npm run test:app-render-guards && npm run test:onboarding-tour && npm run test:token-statistics && npm run test:chat-typewriter"
},
"dependencies": {
"@emotion/react": "11.14.0",
Expand Down
60 changes: 60 additions & 0 deletions frontend/web/scripts/check-chat-typewriter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

// Regression test for issue #70 ("Chat redesign"): the assistant's answers must
// stream in with a typewriter effect (text "being typed") instead of popping in
// all at once, and task-completion reports come from the backend over a `chat`
// message — so the frontend must no longer synthesise its own completion text
// from chatSummary (which would double-post).

const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
const read = (rel) => readFileSync(resolve(root, rel), 'utf8');

// --- TypewriterText reveals text incrementally. ---
const typewriter = read('src/app/components/TypewriterText.tsx');
assert.match(typewriter, /export function TypewriterText/, 'TypewriterText component must be exported');
assert.match(typewriter, /animate\s*=\s*true/, 'TypewriterText must animate by default');
assert.match(typewriter, /setInterval/, 'TypewriterText must reveal characters over time');
assert.match(typewriter, /text\.slice\(0,\s*count\)/, 'TypewriterText must show a growing slice of the text');
assert.match(typewriter, /onTick/, 'TypewriterText must expose an onTick callback so the chat can keep scrolling');
// Non-animated messages (history / user) show the full text immediately.
assert.match(typewriter, /if \(!animate\)/, 'TypewriterText must render full text immediately when animate is false');

// --- Chat renders boss messages through TypewriterText. ---
const chat = read('src/app/components/Chat.tsx');
assert.match(chat, /import \{ TypewriterText \}/, 'Chat must import TypewriterText');
assert.match(chat, /<TypewriterText/, 'Chat must render boss messages via TypewriterText');
assert.match(chat, /animate=\{message\.animate\}/, 'Chat must pass the per-message animate flag to TypewriterText');
assert.match(chat, /animate\?: boolean/, 'ChatMessage must carry an optional animate flag');

// --- App marks fresh boss replies as animated, everything else as static. ---
const app = read('src/app/App.tsx');
assert.match(
app,
/animate:\s*sender === 'boss' && !showProgress/,
'incoming boss chat messages must animate (but not progress placeholders)',
);
assert.match(app, /animate:\s*false/, 'history/user/progress messages must opt out of animation');

// --- The frontend no longer posts its own completion text from chatSummary. ---
const ws = read('src/hooks/useWebSocket.ts');
// Document that completion is now reported by the backend.
assert.match(
ws,
/reports task completion back in the chat/,
'useWebSocket must document that the backend reports completion via a chat message',
);
// The `success` handler must not post a chat message itself (that would
// double-post alongside the backend completion report). Isolate the success
// case and assert it never calls onChatMessage.
const successCase = ws.slice(ws.indexOf("case 'success':"), ws.indexOf("case 'chat':"));
assert.ok(successCase.length > 0, 'could not locate the success case block');
assert.ok(
!/onChatMessage\(/.test(successCase),
'the success handler must not post its own completion chat message (backend reports completion now)',
);

console.log('check-chat-typewriter: all assertions passed');
Loading
Loading