feat(ui): каждая вкладка chat — независимая LLM-сессия #11

Merged
Teraonious merged 1 commits from feat/ui_llm_chat into main 2026-08-21 18:44:27 +05:00
6 changed files with 356 additions and 64 deletions

View File

@@ -21,7 +21,7 @@ var FS embed.FS
// //
// Держим в курсе: если добавить файл в каталог, он автоматически попадёт // Держим в курсе: если добавить файл в каталог, он автоматически попадёт
// в FS, но для детерминированной распаковки список лучше дополнять здесь. // в FS, но для детерминированной распаковки список лучше дополнять здесь.
var Names = []string{"analyst", "dev", "reviewer"} var Names = []string{"analyst", "dev", "reviewer", "chat"}
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents // WriteTo распаковывает всех встроенных агентов в каталог dir/agents
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна. // (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.

17
internal/agents/chat.md Normal file
View File

@@ -0,0 +1,17 @@
---
name: chat
description: Свободный чат с Ratatoskr — прямой диалог с LLM без задачи и JSON-вердиктов
mode: primary
---
Ты — полезный ассистент в диалоговом режиме. Пользователь пишет тебе произвольные
сообщения; отвечай на них прямо и по делу, на русском языке, естественно.
Особенности:
1. Отвечай на вопросы пользователя, помогай с задачами разработки, поясняй код.
2. Не оборачивай ответ в JSON и не возвращай структурированные вердикты — это
свободный диалог, не конвейер задач Ratatoskr.
3. Если тебе не хватает контекста — задай уточняющий вопрос в обычном тексте.
4. Пиши кратко и по существу, если пользователь не просит развёрнуто.
Просто продолжай диалог.

View File

@@ -73,6 +73,35 @@ type App struct {
// nil — headless-режим (без окна). // nil — headless-режим (без окна).
UI ui.Window UI ui.Window
Controller *ui.Controller Controller *ui.Controller
// FreeChat — свободные диалоги с LLM вне машины задач (in-memory).
FreeChat *FreeChat
}
// FreeChat — свободный LLM-чат (без задачи). Каждый UserID (вкладка UI)
// держит свою opencode-сессию, чтобы контекст вкладки не смешивался.
// Транскрипт живёт в памяти окна; здесь только связка uid → opencode sessionID.
type FreeChat struct {
Runner analyst.OpenCodeRunner
Worktree string
sessions map[chat.UserID]string // uid → opencode sessionID
}
// NewFreeChat создаёт сервис свободного чата.
func NewFreeChat(runner analyst.OpenCodeRunner, worktree string) *FreeChat {
return &FreeChat{Runner: runner, Worktree: worktree, sessions: map[chat.UserID]string{}}
}
// Chat отправляет текст пользователя свободной сессии в opencode (agent="chat")
// и возвращает ответ модели. Сессия продолжается (resume по sessionID), поэтому
// каждая вкладка ведёт свой независимый диалог.
func (f *FreeChat) Chat(ctx context.Context, uid chat.UserID, text string) (string, error) {
sid := f.sessions[uid]
res, err := f.Runner.Run(ctx, text, f.Worktree, "chat", sid)
if err != nil {
return "", err
}
f.sessions[uid] = res.SessionID
return res.Stdout, nil
} }
// New читает конфиг и собирает все зависимости. // New читает конфиг и собирает все зависимости.
@@ -212,6 +241,9 @@ func New(configPath, version, updateToken string, noUI bool) (*App, error) {
a.Router = router a.Router = router
a.Worker = w a.Worker = w
// Свободный LLM-чат (вкладки UI без задачи): общий Runner, свой sessionID на uid.
a.FreeChat = NewFreeChat(ocRunner, cfg.Paths.Worktree)
// /status N читает живое состояние сессии через liveProbe-адаптер. // /status N читает живое состояние сессии через liveProbe-адаптер.
coreCtx.Live = &liveProbe{reg: live} coreCtx.Live = &liveProbe{reg: live}
@@ -363,6 +395,10 @@ func (a *App) handleIncoming(inc chat.Incoming) {
} }
a.send(ctx, uid, result.Reply) a.send(ctx, uid, result.Reply)
case isUISession(uid):
// Свободная UI-вкладка (нет активной задачи): свободный LLM-чат.
a.freeChatReply(ctx, uid, text)
default: default:
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.") a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
} }
@@ -427,6 +463,26 @@ func hasArg(text string) bool {
return i > 0 && strings.TrimSpace(s[i:]) != "" return i > 0 && strings.TrimSpace(s[i:]) != ""
} }
// isUISession — true, если UserID принадлежит UI-вкладке-сессии (ui://sess-*).
func isUISession(uid chat.UserID) bool {
return strings.HasPrefix(string(uid), ui.SessionID)
}
// freeChatReply — ответ свободной LLM-сессии (вкладка UI без активной задачи).
func (a *App) freeChatReply(ctx context.Context, uid chat.UserID, text string) {
if a.FreeChat == nil {
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
return
}
reply, err := a.FreeChat.Chat(ctx, uid, text)
if err != nil {
log.Printf("app: free chat %s: %v", uid, err)
a.send(ctx, uid, "Ошибка LLM-чата.")
return
}
a.send(ctx, uid, reply)
}
// ownerUID — куда слать авто-уведомления об обновлении. // ownerUID — куда слать авто-уведомления об обновлении.
func (a *App) ownerUID() chat.UserID { func (a *App) ownerUID() chat.UserID {
if a.Config.Telegram.ChatID != "" { if a.Config.Telegram.ChatID != "" {

View File

@@ -8,6 +8,7 @@ package desktop
import ( import (
"context" "context"
"fmt"
"log" "log"
"strings" "strings"
@@ -25,15 +26,17 @@ import (
const ( const (
selectedPref = "task.selected" selectedPref = "task.selected"
splitHPref = "split.h" splitHPref = "split.h"
splitVPref = "split.v"
) )
// Window — Fyne-окно: chat.Channel + ui.View. // Window — Fyne-окно: chat.Channel + ui.View.
//
// Внутри — вкладки-сессии (ui.Session): каждая вкладка — независимый диалог
// с LLM (свой UserID/Address, свой активный task). Свободные вкладки (без
// задачи) создаются кнопкой «Новый чат»; вкладки задач открываются из списка.
type Window struct { type Window struct {
fyneApp fyne.App fyneApp fyne.App
win fyne.Window win fyne.Window
store ui.Store store ui.Store
commands *ui.Commands
handler chat.Handler handler chat.Handler
@@ -41,14 +44,19 @@ type Window struct {
selected int64 selected int64
// виджеты // виджеты
taskList *TaskListPanel taskList *TaskListPanel
detail *TaskDetailPanel detail *TaskDetailPanel
chat *ChatPanel state *StatePanel
state *StatePanel logs *LogPanel
logs *LogPanel
// вкладки-сессии
tabs *container.AppTabs
sessions map[chat.Address]*ui.Session // Address вкладки → сессия
panels map[chat.Address]*ChatPanel // Address вкладки → панель
tabSession map[*container.TabItem]*ui.Session
nextSess int // счётчик уникальных ui://sess-<n>
hsplit *container.Split hsplit *container.Split
vsplit *container.Split
onQuit func() onQuit func()
} }
@@ -57,12 +65,14 @@ type Window struct {
func New(appID string, store ui.Store) *Window { func New(appID string, store ui.Store) *Window {
fa := app.NewWithID(appID) fa := app.NewWithID(appID)
w := &Window{ w := &Window{
fyneApp: fa, fyneApp: fa,
store: store, store: store,
sessions: map[chat.Address]*ui.Session{},
panels: map[chat.Address]*ChatPanel{},
tabSession: map[*container.TabItem]*ui.Session{},
} }
w.win = fa.NewWindow("Ratatoskr") w.win = fa.NewWindow("Ratatoskr")
w.win.SetFullScreen(true) w.win.SetFullScreen(true)
w.commands = ui.NewCommands(w.submitText)
w.build() w.build()
w.win.SetCloseIntercept(func() { w.win.SetCloseIntercept(func() {
// Закрытие окна = сворачивание (спец 12.6): Core продолжает работать. // Закрытие окна = сворачивание (спец 12.6): Core продолжает работать.
@@ -79,17 +89,12 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
// build собирает виджеты и раскладку. // build собирает виджеты и раскладку.
func (w *Window) build() { func (w *Window) build() {
w.detail = NewTaskDetailPanel() w.detail = NewTaskDetailPanel()
w.chat = NewChatPanel()
w.chat.SetCommands(w.commands)
w.state = NewStatePanel() w.state = NewStatePanel()
w.logs = NewLogPanel() w.logs = NewLogPanel()
// Список задач (слева) // Список задач (слева)
w.taskList = NewTaskListPanel() w.taskList = NewTaskListPanel()
w.taskList.OnSelect(func(id int64) { w.selectTask(id) }) w.taskList.OnSelect(func(id int64) { w.openTaskTab(id) })
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() { quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
w.saveLayout() w.saveLayout()
@@ -99,60 +104,119 @@ func (w *Window) build() {
}) })
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget()) left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). // Центр: вкладки-сессии + кнопка «Новый чат».
details := w.detail.Widget() w.tabs = container.NewAppTabs()
right := container.NewVSplit( w.tabs.OnSelected = func(tab *container.TabItem) {
container.NewScroll(details), // фокус вкладки → показываем детали/состояние её задачи
w.chat.Transcript(), if s := w.tabSession[tab]; s != nil && s.BoundTask > 0 {
) w.selectTask(s.BoundTask)
w.vsplit = right }
}
newBtn := widget.NewButtonWithIcon("Новый чат", theme.ContentAddIcon(), func() {
w.addFreeTab()
})
center := container.NewBorder(newBtn, nil, nil, nil, w.tabs)
// Нижняя панель: вкладки «Логи» + «Состояние». // Нижняя панель: вкладки «Логи» + «Состояние».
bottomTabs := container.NewAppTabs( bottomTabs := container.NewAppTabs(
container.NewTabItem("Логи", w.logs.Widget()), container.NewTabItem("Логи", w.logs.Widget()),
container.NewTabItem("Состояние", w.state.Widget()), container.NewTabItem("Состояние", w.state.Widget()),
) )
bottom := container.NewBorder(nil, nil, nil, nil, bottomTabs)
bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs) w.hsplit = container.NewHSplit(left, container.NewVSplit(center, bottom))
w.win.SetContent(w.hsplit)
w.hsplit = container.NewHSplit(left, right)
root := container.NewVSplit(w.hsplit, bottom)
w.win.SetContent(root)
// Восстановление layout из Preferences. // Восстановление layout из Preferences.
pref := w.fyneApp.Preferences() pref := w.fyneApp.Preferences()
w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30)) w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30))
w.vsplit.SetOffset(pref.FloatWithFallback(splitVPref, 0.45))
// Начальный снимок списка. // Начальный снимок списка.
w.refreshList() w.refreshList()
// Открываем свободную вкладку-сессию по умолчанию.
w.addFreeTab()
} }
// saveLayout сохраняет текущие позиции сплитов в Preferences. // addFreeTab создаёт свободную вкладку-сессию (in-memory диалог с LLM).
// Вызывается при сворачивании окна и перед полным выходом. func (w *Window) addFreeTab() {
func (w *Window) saveLayout() { w.nextSess++
pref := w.fyneApp.Preferences() id := fmt.Sprintf("%s%d", ui.SessionID, w.nextSess)
pref.SetFloat(splitHPref, w.hsplit.Offset) w.addTab(ui.NewFreeSession(chat.UserID(id), chat.Address(id)))
pref.SetFloat(splitVPref, w.vsplit.Offset)
} }
// selectTask загружает детали выбранной задачи (снимки из Store). // addTab создаёт вкладку для сессии и фокусирует её.
func (w *Window) selectTask(id int64) { func (w *Window) addTab(sess *ui.Session) {
w.selected = id panel := NewChatPanel()
w.taskList.Select(id) panel.SetCommands(ui.NewCommands(func(text string) {
w.fyneApp.Preferences().SetInt(selectedPref, int(id)) w.submitText(sess.UserID, sess.Address, text)
}))
content := container.NewBorder(nil, panel.Composer(), nil, nil, panel.Transcript())
tab := container.NewTabItem(sess.Title, content)
w.tabs.Append(tab)
w.sessions[sess.Address] = sess
w.panels[sess.Address] = panel
w.tabSession[tab] = sess
w.tabs.Select(tab)
}
// tabForAddress возвращает вкладку по адресу сессии.
func (w *Window) tabForAddress(addr chat.Address) *container.TabItem {
for tab, s := range w.tabSession {
if s.Address == addr {
return tab
}
}
return nil
}
// openTaskTab открывает вкладку задачи (или фокусирует уже открытую).
//
// Вкладку можно открыть только для задачи, созданной в UI-сессии (ChatID с
// префиксом ui://sess-*): её ChatID уникален и не пересекается с Telegram-каналом.
// Задачи Telegram не открываются в отдельной вкладке — их показывает список и
// детали (selectTask), чтобы не плодить сессию с общим UserID=чат-id.
func (w *Window) openTaskTab(id int64) {
ctx := context.Background() ctx := context.Background()
t, err := w.store.GetTask(ctx, id) t, err := w.store.GetTask(ctx, id)
if err != nil { if err != nil {
w.detail.ShowEmpty()
w.chat.Clear()
return return
} }
w.detail.ShowTask(t)
// Диалог: история + краткие трассы. // Telegram-задача (ChatID без ui://sess-*) — только детали, без вкладки.
if !strings.HasPrefix(t.ChatID, ui.SessionID) {
w.selectTask(id)
return
}
// Если вкладка для задачи уже есть — фокусируем.
for _, s := range w.sessions {
if s.BoundTask == id {
if tab := w.tabForAddress(s.Address); tab != nil {
w.tabs.Select(tab)
return
}
}
}
// Создаём новую вкладку: UserID == task.ChatID (чат задачи уникален).
w.nextSess++
addr := chat.Address(fmt.Sprintf("%s%d", ui.SessionID, w.nextSess))
sess := ui.NewTaskSession(addr, t)
w.addTab(sess)
// Транскрипт из истории задачи.
if panel := w.panels[sess.Address]; panel != nil {
panel.SetTranscript(w.renderHistory(ctx, id))
}
w.selectTask(id)
}
// renderHistory строит транскрипт диалога задачи (история + краткие трассы).
func (w *Window) renderHistory(ctx context.Context, id int64) string {
hist, err := w.store.GetHistory(ctx, id) hist, err := w.store.GetHistory(ctx, id)
if err != nil { if err != nil {
hist = nil hist = nil
@@ -172,9 +236,30 @@ func (w *Window) selectTask(id int64) {
if b.Len() == 0 { if b.Len() == 0 {
b.WriteString("Нет сообщений. /start — начать задачу.") b.WriteString("Нет сообщений. /start — начать задачу.")
} }
w.chat.SetTranscript(b.String()) return b.String()
}
// Состояние: сброс к снимку задач. // saveLayout сохраняет текущие позиции сплитов в Preferences.
// Вызывается при сворачивании окна и перед полным выходом.
func (w *Window) saveLayout() {
pref := w.fyneApp.Preferences()
pref.SetFloat(splitHPref, w.hsplit.Offset)
}
// selectTask загружает детали выбранной задачи (снимки из Store).
func (w *Window) selectTask(id int64) {
w.selected = id
w.taskList.Select(id)
w.fyneApp.Preferences().SetInt(selectedPref, int(id))
ctx := context.Background()
t, err := w.store.GetTask(ctx, id)
if err != nil {
w.detail.ShowEmpty()
w.state.SetText(ui.StateText(nil))
return
}
w.detail.ShowTask(t)
w.refreshState(ctx, id) w.refreshState(ctx, id)
} }
@@ -204,18 +289,34 @@ func (w *Window) appendLog(text string) {
w.logs.Append(text) w.logs.Append(text)
} }
// submitText отправляет ввод пользователя в Router. // submitText отправляет ввод пользователя из вкладки в Router.
func (w *Window) submitText(text string) { func (w *Window) submitText(uid chat.UserID, addr chat.Address, text string) {
if w.handler != nil { if w.handler != nil {
w.handler(chat.Incoming{ w.handler(chat.Incoming{
UserID: ui.UID, UserID: uid,
Address: ui.Address, Address: addr,
Channel: w, Channel: w,
Msg: chat.Message{Text: text}, Msg: chat.Message{Text: text},
}) })
} }
} }
// bindTaskSession связывает свободную вкладку с созданной задачей: когда
// /start в свободном табе создал задачу с ChatID == UserID этой вкладки,
// вкладка становится task-сессией (спец: каждая задача — своя вкладка).
func (w *Window) bindTaskSession(chatID string, taskID int64) {
for addr, s := range w.sessions {
if string(s.UserID) != chatID || s.BoundTask != 0 {
continue
}
s.BoundTask = taskID
if tab := w.tabForAddress(addr); tab != nil {
tab.Text = "Задача #" + ui.Itoa(taskID)
w.tabs.Refresh()
}
}
}
// ---- chat.Channel ---- // ---- chat.Channel ----
// OnMessage регистрирует обработчик входящих (Router). // OnMessage регистрирует обработчик входящих (Router).
@@ -232,30 +333,37 @@ func (w *Window) Run(ctx context.Context) error {
return nil return nil
} }
// Send доставляет сообщение Core в диалог окна. // Send доставляет сообщение Core в диалог вкладки по её Address.
func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error { func (w *Window) Send(_ context.Context, to chat.Address, m chat.Message) error {
panel, ok := w.panels[to]
if !ok {
return nil
}
text := m.Text text := m.Text
if len(m.Options) > 0 { if len(m.Options) > 0 {
var sb strings.Builder var sb strings.Builder
sb.WriteString(text) sb.WriteString(text)
for i, opt := range m.Options { for _, opt := range m.Options {
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(opt.Label) sb.WriteString(opt.Label)
_ = i
} }
text = sb.String() text = sb.String()
} }
fyne.Do(func() { w.chat.Append("🤖 " + text) }) fyne.Do(func() { panel.Append("🤖 " + text) })
return nil return nil
} }
// Ask — вопрос с вариантами (отображается в диалоге). // Ask — вопрос с вариантами (отображается в диалоге вкладки).
func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error { func (w *Window) Ask(_ context.Context, to chat.Address, m chat.Message) error {
panel, ok := w.panels[to]
if !ok {
return nil
}
text := m.Text text := m.Text
for _, opt := range m.Options { for _, opt := range m.Options {
text += "\n" + opt.Label text += "\n" + opt.Label
} }
fyne.Do(func() { w.chat.Append("❓ " + text) }) fyne.Do(func() { panel.Append("❓ " + text) })
return nil return nil
} }
@@ -272,6 +380,8 @@ func (w *Window) Close() error {
func (w *Window) OnTaskCreated(e events.TaskCreated) { func (w *Window) OnTaskCreated(e events.TaskCreated) {
fyne.Do(func() { fyne.Do(func() {
w.refreshList() w.refreshList()
// /start в свободной вкладке: вкладка привязывается к задаче.
w.bindTaskSession(e.ChatID, e.ID)
if w.selected == 0 { if w.selected == 0 {
w.selectTask(e.ID) w.selectTask(e.ID)
} }
@@ -308,8 +418,13 @@ func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) {
func (w *Window) OnHistoryAppended(e events.HistoryAppended) { func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
fyne.Do(func() { fyne.Do(func() {
if w.selected == e.TaskID { // история приходит в диалог вкладки задачи
w.chat.Append(ui.FormatRole(e.Role) + e.Content) for addr, s := range w.sessions {
if s.BoundTask == e.TaskID {
if panel, ok := w.panels[addr]; ok {
panel.Append(ui.FormatRole(e.Role) + e.Content)
}
}
} }
}) })
} }
@@ -329,3 +444,9 @@ func (w *Window) OnAgentActivity(e events.AgentActivity) {
func (w *Window) OnLog(e events.LogLine) { func (w *Window) OnLog(e events.LogLine) {
w.appendLog(e.Text) w.appendLog(e.Text)
} }
// compile-time проверка: окно реализует chat.Channel и ui.View.
var (
_ chat.Channel = (*Window)(nil)
_ ui.View = (*Window)(nil)
)

43
internal/ui/session.go Normal file
View File

@@ -0,0 +1,43 @@
package ui
import (
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Session — одна вкладка-сессия UI: независимый диалог с LLM.
//
// Каждая вкладка имеет собственный UserID и Address, поэтому core видит её как
// отдельный чат со своим активным task (политика «одна задача на чат» работает
// из коробки). Свободная сессия (BoundTask==0) — in-memory диалог с моделью
// вне машины задач; task-сессия (BoundTask>0) — диалог конкретной задачи, у
// которой UserID == task.ChatID (чат задачи уникален).
type Session struct {
UserID chat.UserID
Address chat.Address
Title string
BoundTask int64 // 0 = свободный чат; >0 — задача, к которой привязана вкладка
}
// NewFreeSession создаёт свободную сессию (без задачи). userID/address должны
// быть уникальными (например "ui://sess-<n>"). Транскрипт живёт только в памяти.
func NewFreeSession(userID chat.UserID, address chat.Address) *Session {
return &Session{UserID: userID, Address: address, Title: "Новый чат", BoundTask: 0}
}
// NewTaskSession создаёт сессию, привязанную к задаче. UserID == task.ChatID,
// чтобы Router/core находили активную задачу этой вкладки. address — уникальный
// адрес доставки вкладки (не должен совпадать с UserID других вкладок).
func NewTaskSession(address chat.Address, t storage.Task) *Session {
title := t.Title
if title == "" {
title = "Задача #" + Itoa(t.ID)
}
return &Session{UserID: chat.UserID(t.ChatID), Address: address, Title: title, BoundTask: t.ID}
}
// SessionID — префикс идентификаторов UI-сессий (и UserID, и Address).
const SessionID = "ui://sess-"
// IsFreeSession — true, если вкладка не привязана к задаче.
func IsFreeSession(s *Session) bool { return s != nil && s.BoundTask == 0 }

View File

@@ -0,0 +1,55 @@
package ui
import (
"testing"
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
func TestNewFreeSession(t *testing.T) {
s := NewFreeSession(chat.UserID("ui://sess-1"), chat.Address("ui://sess-1"))
if s.UserID != chat.UserID("ui://sess-1") {
t.Errorf("UserID = %q, want ui://sess-1", s.UserID)
}
if s.Address != chat.Address("ui://sess-1") {
t.Errorf("Address = %q, want ui://sess-1", s.Address)
}
if s.BoundTask != 0 {
t.Errorf("BoundTask = %d, want 0", s.BoundTask)
}
if !IsFreeSession(s) {
t.Error("free session must be free")
}
}
func TestNewTaskSessionUsesTaskChatID(t *testing.T) {
task := &storage.Task{ID: 7, ChatID: "ui://sess-3", Title: "Фича"}
s := NewTaskSession(chat.Address("ui://sess-3"), *task)
if s.UserID != chat.UserID("ui://sess-3") {
t.Errorf("UserID = %q, want task.ChatID", s.UserID)
}
if s.BoundTask != 7 {
t.Errorf("BoundTask = %d, want 7", s.BoundTask)
}
if s.Title != "Фича" {
t.Errorf("Title = %q, want Фича", s.Title)
}
if IsFreeSession(s) {
t.Error("task session must not be free")
}
}
func TestNewTaskSessionTitleFallback(t *testing.T) {
task := &storage.Task{ID: 3, ChatID: "ui://sess-3"}
s := NewTaskSession(chat.Address("ui://sess-3"), *task)
if s.Title != "Задача #3" {
t.Errorf("Title = %q, want Задача #3", s.Title)
}
}
func TestIsFreeSessionNil(t *testing.T) {
if IsFreeSession(nil) {
t.Error("nil is not free")
}
}