mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-05 02:13:48 +00:00
The sessions.reset RPC only reset the native session store, leaving the Claude CLI-backed history (.jsonl + CLAUDE.md) in place. The /reset chat command already clears it via providers.ResetCLISession, so RPC callers got an incomplete reset for claude-cli agents — stale history was resumed on the next turn. Mirror the chat path by calling ResetCLISession from handleReset. It is a no-op when the CLI provider is unused. The call is wired through an overridable package var so the behavior can be regression-tested.
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// SessionsMethods handles sessions.list, sessions.preview, sessions.patch, sessions.delete, sessions.reset.
|
|
type SessionsMethods struct {
|
|
sessions store.SessionStore
|
|
eventBus bus.EventPublisher
|
|
cfg *config.Config
|
|
}
|
|
|
|
func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher, cfg *config.Config) *SessionsMethods {
|
|
return &SessionsMethods{sessions: sess, eventBus: eventBus, cfg: cfg}
|
|
}
|
|
|
|
func (m *SessionsMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodSessionsList, m.handleList)
|
|
router.Register(protocol.MethodSessionsPreview, m.handlePreview)
|
|
router.Register(protocol.MethodSessionsPatch, m.handlePatch)
|
|
router.Register(protocol.MethodSessionsDelete, m.handleDelete)
|
|
router.Register(protocol.MethodSessionsReset, m.handleReset)
|
|
router.Register(protocol.MethodSessionsCompact, m.handleCompact)
|
|
}
|
|
|
|
type sessionsListParams struct {
|
|
AgentID string `json:"agentId"`
|
|
Channel string `json:"channel"` // optional: filter by channel prefix ("ws", "telegram")
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
func (m *SessionsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params sessionsListParams
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.Limit <= 0 {
|
|
params.Limit = 20
|
|
}
|
|
|
|
opts := store.SessionListOpts{
|
|
AgentID: params.AgentID,
|
|
Channel: params.Channel,
|
|
Limit: params.Limit,
|
|
Offset: params.Offset,
|
|
TenantID: store.TenantIDFromContext(ctx),
|
|
}
|
|
// Role-based filtering: admins/owners see all sessions; regular users see only their own.
|
|
// Tenant scope is always applied above — admin sees all sessions within the tenant.
|
|
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
|
opts.UserID = client.UserID()
|
|
}
|
|
|
|
result := m.sessions.ListPagedRich(ctx, opts)
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"sessions": result.Sessions,
|
|
"total": result.Total,
|
|
"limit": params.Limit,
|
|
"offset": params.Offset,
|
|
}))
|
|
}
|
|
|
|
type sessionKeyParams struct {
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params sessionKeyParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
|
|
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
|
sess := m.sessions.Get(ctx, params.Key)
|
|
if sess == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
|
|
return
|
|
}
|
|
if sess.UserID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
|
|
return
|
|
}
|
|
}
|
|
|
|
history := m.sessions.GetHistory(ctx, params.Key)
|
|
summary := m.sessions.GetSummary(ctx, params.Key)
|
|
|
|
// Sign file URLs before delivery — sessions store clean paths.
|
|
secret := httpapi.FileSigningKey()
|
|
for i := range history {
|
|
history[i].Content = httpapi.SignFileURLs(history[i].Content, secret)
|
|
for j := range history[i].MediaRefs {
|
|
history[i].MediaRefs[j].Path = httpapi.SignMediaPath(history[i].MediaRefs[j].Path, secret)
|
|
}
|
|
}
|
|
summary = httpapi.SignFileURLs(summary, secret)
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"key": params.Key,
|
|
"messages": history,
|
|
"summary": summary,
|
|
}))
|
|
}
|
|
|
|
// handlePatch updates session metadata fields.
|
|
// Matching TS sessions.patch (src/gateway/server-methods/sessions.ts:237-287).
|
|
func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Key string `json:"key"`
|
|
Label *string `json:"label,omitempty"`
|
|
Model *string `json:"model,omitempty"`
|
|
Metadata map[string]string `json:"metadata,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
|
|
if params.Key == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "key")))
|
|
return
|
|
}
|
|
|
|
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
|
sess := m.sessions.Get(ctx, params.Key)
|
|
if sess == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
|
|
return
|
|
}
|
|
if sess.UserID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Apply label patch
|
|
if params.Label != nil {
|
|
m.sessions.SetLabel(ctx, params.Key, *params.Label)
|
|
}
|
|
|
|
// Apply model patch
|
|
if params.Model != nil {
|
|
m.sessions.UpdateMetadata(ctx, params.Key, *params.Model, "", "")
|
|
}
|
|
|
|
// Apply metadata patch
|
|
if len(params.Metadata) > 0 {
|
|
m.sessions.SetSessionMetadata(ctx, params.Key, params.Metadata)
|
|
}
|
|
|
|
// Save changes to DB
|
|
m.sessions.Save(ctx, params.Key)
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
"key": params.Key,
|
|
}))
|
|
emitAudit(m.eventBus, client, "session.patched", "session", params.Key)
|
|
}
|
|
|
|
func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params sessionKeyParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
|
|
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
|
sess := m.sessions.Get(ctx, params.Key)
|
|
if sess == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
|
|
return
|
|
}
|
|
if sess.UserID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := m.sessions.Delete(ctx, params.Key); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
}))
|
|
emitAudit(m.eventBus, client, "session.deleted", "session", params.Key)
|
|
}
|
|
|
|
// cliSessionReset clears the Claude CLI-backed session for a key. It is a
|
|
// package var so tests can stub it; defaults to the real provider helper.
|
|
var cliSessionReset = providers.ResetCLISession
|
|
|
|
func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params sessionKeyParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
|
|
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
|
sess := m.sessions.Get(ctx, params.Key)
|
|
if sess == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
|
|
return
|
|
}
|
|
if sess.UserID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
|
|
return
|
|
}
|
|
}
|
|
|
|
m.sessions.Reset(ctx, params.Key)
|
|
// Also clear the Claude CLI session (.jsonl + CLAUDE.md) so the RPC reset
|
|
// matches the /reset chat command for claude-cli-backed agents (no-op when
|
|
// the CLI provider is unused).
|
|
cliSessionReset("", params.Key)
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
}))
|
|
emitAudit(m.eventBus, client, "session.reset", "session", params.Key)
|
|
}
|
|
|
|
type sessionCompactParams struct {
|
|
Key string `json:"key"`
|
|
KeepLast int `json:"keepLast,omitempty"` // default 4
|
|
}
|
|
|
|
// handleCompact truncates session history to the last N messages.
|
|
// Issue 958: Manual session compaction API (truncate-only, no LLM summarization).
|
|
func (m *SessionsMethods) handleCompact(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params sessionCompactParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
|
|
if params.Key == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "key is required"))
|
|
return
|
|
}
|
|
|
|
keepLast := params.KeepLast
|
|
if keepLast <= 0 {
|
|
keepLast = 4 // default: keep last 2 exchanges
|
|
}
|
|
|
|
// Auth check
|
|
sess := m.sessions.Get(ctx, params.Key)
|
|
if sess == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
|
|
return
|
|
}
|
|
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
|
if sess.UserID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
|
|
return
|
|
}
|
|
}
|
|
|
|
history := m.sessions.GetHistory(ctx, params.Key)
|
|
originalLen := len(history)
|
|
if originalLen < 6 {
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
"message": "session too short to compact",
|
|
"kept": originalLen,
|
|
}))
|
|
return
|
|
}
|
|
|
|
// Truncate history to last N messages
|
|
m.sessions.TruncateHistory(ctx, params.Key, keepLast)
|
|
m.sessions.IncrementCompaction(ctx, params.Key)
|
|
m.sessions.Save(ctx, params.Key)
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
"original": originalLen,
|
|
"kept": keepLast,
|
|
}))
|
|
emitAudit(m.eventBus, client, "session.compacted", "session", params.Key)
|
|
}
|