feat: goose2 context window usage in chat input (#8613)
Some checks are pending
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Goose 2 CI / Lint & Format (push) Waiting to run
Goose 2 CI / Unit Tests (push) Waiting to run
Goose 2 CI / Desktop Build & E2E (push) Waiting to run
Goose 2 CI / Rust Lint (push) Waiting to run
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho 2026-04-19 18:53:44 -10:00 committed by GitHub
parent 765213561e
commit a7d78ee59e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1035 additions and 73 deletions

View file

@ -40,7 +40,7 @@ use sacp::schema::{
SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse,
SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents,
ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
ToolCallUpdateFields, ToolKind,
ToolCallUpdateFields, ToolKind, Usage, UsageUpdate,
};
use sacp::util::MatchDispatchFrom;
use sacp::{
@ -603,6 +603,22 @@ fn build_config_options(
]
}
fn to_nonnegative_u64(value: Option<i32>) -> Option<u64> {
value.and_then(|v| u64::try_from(v).ok())
}
fn build_prompt_usage(session: &Session) -> Option<Usage> {
let total = to_nonnegative_u64(session.total_tokens)?;
let input = to_nonnegative_u64(session.input_tokens).unwrap_or(0);
let output = to_nonnegative_u64(session.output_tokens).unwrap_or(0);
Some(Usage::new(total, input, output))
}
fn build_usage_update(session: &Session, context_limit: usize) -> UsageUpdate {
let used = session.total_tokens.unwrap_or(0).max(0) as u64;
UsageUpdate::new(used, context_limit as u64)
}
impl GooseAcpAgent {
pub fn permission_manager(&self) -> Arc<PermissionManager> {
Arc::clone(&self.permission_manager)
@ -1383,27 +1399,38 @@ impl GooseAcpAgent {
// Resolve provider + model from config so we can include the current
// model in the response without waiting for the full agent setup.
let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await;
let initial_usage_update = resolved
.as_ref()
.ok()
.map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit()));
let (model_state, config_options) =
build_eager_config(&resolved, &mode_state, &goose_session).await;
let session_id = SessionId::new(thread_id.clone());
self.spawn_agent_setup(
cx,
agent_tx,
AgentSetupRequest {
session_id: SessionId::new(thread_id.clone()),
session_id: session_id.clone(),
goose_session,
mcp_servers: args.mcp_servers,
resolved_provider: resolved.ok(),
resolved_provider: resolved.as_ref().ok().cloned(),
},
);
let mut response = NewSessionResponse::new(SessionId::new(thread_id)).modes(mode_state);
let mut response = NewSessionResponse::new(session_id.clone()).modes(mode_state);
if let Some(ms) = model_state {
response = response.models(ms);
}
if let Some(co) = config_options {
response = response.config_options(co);
}
if let Some(usage_update) = initial_usage_update {
cx.send_notification(SessionNotification::new(
session_id,
SessionUpdate::UsageUpdate(usage_update),
))?;
}
debug!(
target: "perf",
sid = %sid,
@ -1748,6 +1775,16 @@ impl GooseAcpAgent {
let mode_state = build_mode_state(loaded_mode)?;
let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await;
let initial_usage_update = resolved
.as_ref()
.ok()
.map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit()))
.or_else(|| {
goose_session
.model_config
.as_ref()
.map(|mc| build_usage_update(&goose_session, mc.context_limit()))
});
let (model_state, config_options) =
build_eager_config(&resolved, &mode_state, &goose_session).await;
@ -1769,6 +1806,12 @@ impl GooseAcpAgent {
if let Some(co) = config_options {
response = response.config_options(co);
}
if let Some(usage_update) = initial_usage_update {
cx.send_notification(SessionNotification::new(
args.session_id.clone(),
SessionUpdate::UsageUpdate(usage_update),
))?;
}
debug!(
target: "perf",
sid = %sid,
@ -1882,10 +1925,30 @@ impl GooseAcpAgent {
}
}
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(&thread_id) {
session.cancel_token = None;
{
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(&thread_id) {
session.cancel_token = None;
}
}
let session = self
.session_manager
.get_session(&internal_session_id, false)
.await
.map_err(|e| {
sacp::Error::internal_error().data(format!("Failed to load session: {}", e))
})?;
let provider = agent.provider().await.map_err(|e| {
sacp::Error::internal_error().data(format!("Failed to get provider: {}", e))
})?;
let usage_update =
build_usage_update(&session, provider.get_model_config().context_limit());
cx.send_notification(SessionNotification::new(
args.session_id.clone(),
SessionUpdate::UsageUpdate(usage_update),
))?;
debug!(
target: "perf",
sid = %sid,
@ -1894,11 +1957,17 @@ impl GooseAcpAgent {
cancelled = was_cancelled,
"perf: prompt done"
);
Ok(PromptResponse::new(if was_cancelled {
let stop_reason = if was_cancelled {
StopReason::Cancelled
} else {
StopReason::EndTurn
}))
};
let mut response = PromptResponse::new(stop_reason);
if let Some(usage) = build_prompt_usage(&session) {
response = response.usage(usage);
}
Ok(response)
}
async fn on_cancel(&self, args: CancelNotification) -> Result<(), sacp::Error> {
@ -3486,6 +3555,80 @@ print(\"hello, world\")
.map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect())
}
fn make_session_with_usage(
total_tokens: Option<i32>,
input_tokens: Option<i32>,
output_tokens: Option<i32>,
accumulated_total_tokens: Option<i32>,
accumulated_input_tokens: Option<i32>,
accumulated_output_tokens: Option<i32>,
) -> Session {
Session {
id: "session-1".to_string(),
working_dir: PathBuf::from("/tmp"),
name: "ACP Session".to_string(),
user_set_name: false,
session_type: SessionType::Acp,
created_at: Default::default(),
updated_at: Default::default(),
extension_data: goose::session::ExtensionData::default(),
total_tokens,
input_tokens,
output_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
schedule_id: None,
recipe: None,
user_recipe_values: None,
conversation: None,
message_count: 0,
provider_name: None,
model_config: None,
goose_mode: GooseMode::default(),
thread_id: None,
}
}
#[test]
fn test_build_prompt_usage_uses_current_turn_tokens() {
let session = make_session_with_usage(
Some(120),
Some(80),
Some(40),
Some(360),
Some(210),
Some(150),
);
let usage = build_prompt_usage(&session).expect("usage should be present");
assert_eq!(usage.total_tokens, 120);
assert_eq!(usage.input_tokens, 80);
assert_eq!(usage.output_tokens, 40);
}
#[test]
fn test_build_prompt_usage_falls_back_to_current_tokens() {
let session = make_session_with_usage(Some(120), Some(80), Some(40), None, None, None);
let usage = build_prompt_usage(&session).expect("usage should be present");
assert_eq!(usage.total_tokens, 120);
assert_eq!(usage.input_tokens, 80);
assert_eq!(usage.output_tokens, 40);
}
#[test]
fn test_build_prompt_usage_requires_total_tokens() {
let session = make_session_with_usage(None, Some(80), Some(40), None, None, None);
assert!(build_prompt_usage(&session).is_none());
}
#[test]
fn test_build_usage_update_clamps_negative_used_to_zero() {
let session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None);
let usage = build_usage_update(&session, 258_000);
assert_eq!(usage.used, 0);
assert_eq!(usage.size, 258_000);
}
#[test_case(
GooseMode::Auto
=> Ok(SessionModeState::new(

View file

@ -88,10 +88,24 @@ dev:
# Override with e.g. RUST_LOG=info just dev to disable.
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
PROJECT_DIR=$(pwd)
GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose"
export GOOSE_BIN
REPO_ROOT=$(cd ../.. && pwd)
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}"
elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}"
else
unset GOOSE_BIN
fi
EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"cd ${PROJECT_DIR} && exec pnpm exec vite --port ${VITE_PORT} --strictPort\",\"cwd\":\".\",\"wait\":false}}}")
if [[ -n "${GOOSE_BIN:-}" ]]; then
echo "Using local goose binary: ${GOOSE_BIN}"
else
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
fi
# In worktrees, generate a labeled icon so you can tell instances apart
if git rev-parse --is-inside-work-tree &>/dev/null; then
GIT_DIR=$(git rev-parse --git-dir)
@ -117,14 +131,29 @@ dev-debug:
#!/usr/bin/env bash
set -euo pipefail
VITE_PORT={{ vite_port }}
export VITE_PORT
# Enable perf logs in the child `goose serve` process by default.
# Override with e.g. RUST_LOG=info just dev-debug to disable.
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
PROJECT_DIR=$(pwd)
GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose"
export GOOSE_BIN
REPO_ROOT=$(cd ../.. && pwd)
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}"
elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}"
else
unset GOOSE_BIN
fi
EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort\",\"cwd\":\"..\",\"wait\":false}}}")
if [[ -n "${GOOSE_BIN:-}" ]]; then
echo "Using local goose binary: ${GOOSE_BIN}"
else
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
fi
# In worktrees, generate a labeled icon so you can tell instances apart
if git rev-parse --is-inside-work-tree &>/dev/null; then
GIT_DIR=$(git rev-parse --git-dir)

View file

@ -13,7 +13,17 @@ const EXCEPTIONS = {
"src/features/chat/ui/ChatView.tsx": {
limit: 570,
justification:
"ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here. Includes gated [perf:chatview] logging via perfLog (dev-only by default).",
"ACP prewarm guards, project-aware working dir selection, working context sync, chat bootstrapping, context-ring compaction wiring, and gated [perf:chatview] logging via perfLog (dev-only by default).",
},
"src/features/chat/hooks/useChat.ts": {
limit: 510,
justification:
"Session preparation, provider/model handoff, persona-aware sends, cancellation, and compaction replay still live in one chat lifecycle hook.",
},
"src/shared/api/acpNotificationHandler.ts": {
limit: 550,
justification:
"ACP replay/live update handling, pending session buffering, model/config propagation, and streaming perf tracking still share one notification entrypoint.",
},
"src/features/chat/ui/__tests__/ContextPanel.test.tsx": {
limit: 550,

View file

@ -0,0 +1,228 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Message } from "@/shared/types/messages";
import { useChatStore } from "../../stores/chatStore";
import { clearReplayBuffer, ensureReplayBuffer } from "../replayBuffer";
const mockAcpSendMessage = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
acpCancelSession: vi.fn(),
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
acpPrepareSession: vi.fn(),
acpSetModel: vi.fn(),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
function createDeferredPromise<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function createTextMessage(
id: string,
role: Message["role"],
text: string,
): Message {
return {
id,
role,
created: 0,
content: [{ type: "text", text }],
metadata: {
userVisible: true,
agentVisible: role !== "system",
},
};
}
describe("useChat compaction", () => {
beforeEach(() => {
mockAcpSendMessage.mockReset();
mockAcpLoadSession.mockReset();
mockGetGooseSessionId.mockReset();
clearReplayBuffer("session-1");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
activeSessionId: null,
isConnected: true,
loadingSessionIds: new Set<string>(),
});
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpLoadSession.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
});
it("reloads compacted history after sending the compact command", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
mockAcpLoadSession.mockImplementation(async (sessionId: string) => {
const buffer = ensureReplayBuffer(sessionId);
buffer.push(createTextMessage("user-1", "user", "Before compact"));
buffer.push(createTextMessage("compact-1", "user", "/compact/compact"));
buffer.push(
createTextMessage("assistant-1", "assistant", "After compact"),
);
});
useChatStore
.getState()
.setMessages("session-1", [
createTextMessage("stale-1", "assistant", "Stale"),
]);
const { result } = renderHook(() => useChat("session-1"));
await act(async () => {
await result.current.compactConversation();
});
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"/compact",
undefined,
);
expect(mockAcpLoadSession).toHaveBeenCalledWith(
"session-1",
"goose-session-1",
undefined,
);
const messages = useChatStore.getState().messagesBySession["session-1"];
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(messages).toEqual([
createTextMessage("user-1", "user", "Before compact"),
createTextMessage("assistant-1", "assistant", "After compact"),
]);
expect(runtime.chatState).toBe("idle");
expect(runtime.error).toBeNull();
expect(useChatStore.getState().loadingSessionIds.has("session-1")).toBe(
false,
);
});
it("blocks new sends while compaction is in flight", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
const compactDeferred = createDeferredPromise();
mockAcpSendMessage.mockImplementation(
(_sessionId: string, prompt: string) =>
prompt === "/compact" ? compactDeferred.promise : Promise.resolve(),
);
const { result } = renderHook(() => useChat("session-1"));
let compactPromise!: Promise<void>;
await act(async () => {
compactPromise = result.current.compactConversation();
await Promise.resolve();
});
expect(
useChatStore.getState().getSessionRuntime("session-1").chatState,
).toBe("compacting");
await act(async () => {
await result.current.sendMessage("Hello during compact");
});
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"/compact",
undefined,
);
expect(
useChatStore.getState().messagesBySession["session-1"],
).toBeUndefined();
expect(
useChatStore.getState().getSessionRuntime("session-1").chatState,
).toBe("compacting");
compactDeferred.resolve();
await act(async () => {
await compactPromise;
});
expect(
useChatStore.getState().getSessionRuntime("session-1").chatState,
).toBe("idle");
});
it("ignores a second compact request while the first one is still in flight", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
const compactDeferred = createDeferredPromise();
mockAcpSendMessage.mockImplementation(
(_sessionId: string, prompt: string) =>
prompt === "/compact" ? compactDeferred.promise : Promise.resolve(),
);
const { result } = renderHook(() => useChat("session-1"));
let firstCompact!: Promise<void>;
let secondCompact!: Promise<void>;
await act(async () => {
firstCompact = result.current.compactConversation();
secondCompact = result.current.compactConversation();
await Promise.resolve();
});
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"/compact",
undefined,
);
expect(mockAcpLoadSession).not.toHaveBeenCalled();
expect(
useChatStore.getState().getSessionRuntime("session-1").chatState,
).toBe("compacting");
compactDeferred.resolve();
await act(async () => {
await Promise.all([firstCompact, secondCompact]);
});
expect(mockAcpLoadSession).toHaveBeenCalledTimes(1);
expect(
useChatStore.getState().getSessionRuntime("session-1").chatState,
).toBe("idle");
});
it("surfaces an error when compacting before the session is prepared", async () => {
const { result } = renderHook(() => useChat("session-1"));
await act(async () => {
await result.current.compactConversation();
});
expect(mockAcpSendMessage).not.toHaveBeenCalled();
expect(mockAcpLoadSession).not.toHaveBeenCalled();
const messages = useChatStore.getState().messagesBySession["session-1"];
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(messages).toHaveLength(1);
expect(messages[0].content).toEqual([
{
type: "systemNotification",
notificationType: "error",
text: "Session not prepared. Send a message before compacting.",
},
]);
expect(runtime.error).toBe(
"Session not prepared. Send a message before compacting.",
);
});
});

View file

@ -4,19 +4,27 @@ import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatStore } from "../../stores/chatStore";
import { useChatSessionStore } from "../../stores/chatSessionStore";
import type { Message } from "@/shared/types/messages";
import { clearReplayBuffer } from "../replayBuffer";
const mockAcpSendMessage = vi.fn();
const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args),
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args),
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
function addStreamingAssistantMessage(
@ -53,7 +61,14 @@ function createDeferredPromise<T = void>() {
describe("useChat", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAcpSendMessage.mockReset();
mockAcpCancelSession.mockReset();
mockAcpLoadSession.mockReset();
mockAcpPrepareSession.mockReset();
mockAcpSetModel.mockReset();
mockGetGooseSessionId.mockReset();
clearReplayBuffer("session-1");
clearReplayBuffer("session-2");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
@ -96,9 +111,12 @@ describe("useChat", () => {
personaEditorOpen: false,
editingPersona: null,
});
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpCancelSession.mockResolvedValue(true);
mockAcpLoadSession.mockResolvedValue(undefined);
mockAcpPrepareSession.mockResolvedValue(undefined);
mockAcpSetModel.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
});
it("cancels the active override persona instead of the hook default persona", async () => {

View file

@ -1,18 +1,23 @@
import { useCallback, useRef } from "react";
import { useChatStore } from "../stores/chatStore";
import { useChatSessionStore } from "../stores/chatSessionStore";
import { clearReplayBuffer, getAndDeleteReplayBuffer } from "./replayBuffer";
import {
type ChatAttachmentDraft,
type Message,
createSystemNotificationMessage,
createUserMessage,
getTextContent,
} from "@/shared/types/messages";
import type { ChatState, TokenState } from "@/shared/types/chat";
import {
acpSendMessage,
acpCancelSession,
acpLoadSession,
acpPrepareSession,
acpSetModel,
} from "@/shared/api/acp";
import { getGooseSessionId } from "@/shared/api/acpSessionTracker";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import {
getSessionTitleFromDraft,
@ -26,6 +31,26 @@ import {
buildMessageAttachments,
} from "../lib/attachments";
// TODO: Remove this fallback once goose2 has first-class /-commands.
const MANUAL_COMPACT_TRIGGER = "/compact";
function isManualCompactCommandMessage(message: Message): boolean {
if (message.role !== "user") {
return false;
}
const normalizedText = getTextContent(message).replace(/\s+/g, "");
if (!normalizedText) {
return false;
}
return normalizedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0;
}
function removeManualCompactCommandMessages(messages: Message[]): Message[] {
return messages.filter((message) => !isManualCompactCommandMessage(message));
}
function getErrorMessage(error: unknown): string {
// Tauri command rejections typically arrive as plain strings, so handle
// that shape first before falling back to standard Error objects.
@ -134,10 +159,14 @@ export function useChat(
const tSendStart = performance.now();
const images = buildAcpImages(attachments);
const hasAttachments = (attachments?.length ?? 0) > 0;
const currentChatState = useChatStore
.getState()
.getSessionRuntime(sessionId).chatState;
if (
(!text.trim() && !hasAttachments) ||
chatState === "streaming" ||
chatState === "thinking"
currentChatState === "streaming" ||
currentChatState === "thinking" ||
currentChatState === "compacting"
)
return;
perfLog(
@ -318,7 +347,6 @@ export function useChat(
},
[
sessionId,
chatState,
store,
providerOverride,
systemPromptOverride,
@ -387,6 +415,78 @@ export function useChat(
store.setPendingAssistantProvider(sessionId, null);
}, [sessionId, store]);
const compactConversation = useCallback(async () => {
const currentChatState = useChatStore
.getState()
.getSessionRuntime(sessionId).chatState;
if (currentChatState !== "idle") {
return;
}
const effectivePersonaInfo = resolvePersonaInfo();
const gooseSessionId = getGooseSessionId(
sessionId,
effectivePersonaInfo?.id,
);
if (!gooseSessionId) {
const errorMessage =
"Session not prepared. Send a message before compacting.";
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return;
}
store.setActiveSession(sessionId);
store.setChatState(sessionId, "compacting");
store.setStreamingMessageId(sessionId, null);
store.setError(sessionId, null);
store.setSessionLoading(sessionId, true);
clearReplayBuffer(sessionId);
try {
const sendOptions = effectivePersonaInfo?.id
? { personaId: effectivePersonaInfo.id }
: undefined;
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
// Command responses are streamed via prompt notifications, but the ACP
// layer does not currently forward history replacement events. Drop those
// transient chunks and refresh the session from replay instead.
clearReplayBuffer(sessionId);
const workingDir = await getWorkingDir?.();
await acpLoadSession(sessionId, gooseSessionId, workingDir);
store.setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
if (buffer) {
store.setMessages(
sessionId,
removeManualCompactCommandMessages(buffer),
);
}
} catch (err) {
clearReplayBuffer(sessionId);
store.setSessionLoading(sessionId, false);
const errorMessage = getErrorMessage(err);
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
} finally {
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
store.setSessionLoading(sessionId, false);
}
}, [getWorkingDir, resolvePersonaInfo, sessionId, store]);
const stopStreaming = stopGeneration;
return {
@ -400,6 +500,7 @@ export function useChat(
stopStreaming,
retryLastMessage,
clearChat,
compactConversation,
isStreaming,
};
}

View file

@ -64,6 +64,9 @@ interface ChatInputProps {
}) => void;
contextTokens?: number;
contextLimit?: number;
onCompactContext?: () => void | Promise<void>;
canCompactContext?: boolean;
isCompactingContext?: boolean;
}
export function ChatInput({
@ -94,6 +97,9 @@ export function ChatInput({
onCreateProject,
contextTokens = 0,
contextLimit = 0,
onCompactContext,
canCompactContext = false,
isCompactingContext = false,
}: ChatInputProps) {
const { t } = useTranslation("chat");
const [text, setTextRaw] = useState(initialValue);
@ -429,6 +435,9 @@ export function ChatInput({
onCreateProject={onCreateProject}
contextTokens={contextTokens}
contextLimit={contextLimit}
onCompactContext={onCompactContext}
canCompactContext={canCompactContext}
isCompactingContext={isCompactingContext}
canSend={canSend}
isStreaming={isStreaming}
hasQueuedMessage={hasQueuedMessage}

View file

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Mic,
ArrowUp,
@ -24,6 +24,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Progress } from "@/shared/ui/progress";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
import { AgentModelPicker } from "./AgentModelPicker";
import type { ModelOption } from "../types";
@ -77,6 +79,9 @@ interface ChatInputToolbarProps {
contextTokens: number;
contextLimit: number;
// Actions
canCompactContext?: boolean;
isCompactingContext?: boolean;
onCompactContext?: () => void | Promise<void>;
canSend: boolean;
isStreaming: boolean;
hasQueuedMessage: boolean;
@ -108,6 +113,9 @@ export function ChatInputToolbar({
onCreateProject,
contextTokens,
contextLimit,
canCompactContext = false,
isCompactingContext = false,
onCompactContext,
canSend,
isStreaming,
hasQueuedMessage,
@ -121,6 +129,7 @@ export function ChatInputToolbar({
const { t } = useTranslation("chat");
const { formatNumber } = useLocaleFormatting();
const { readyAgentIds } = useAgentProviderStatus();
const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false);
const agentProviders = useMemo(() => {
const seen = new Set<string>();
@ -156,6 +165,21 @@ export function ChatInputToolbar({
const projectTitle = selectedProject?.workingDirs.length
? selectedProject.workingDirs.join(", ")
: undefined;
const contextProgress =
contextLimit > 0 ? Math.min(contextTokens / contextLimit, 1) : 0;
const contextPercentDigits =
contextProgress > 0 && contextProgress < 0.1 ? 1 : 0;
const usedPercentLabel = formatNumber(contextProgress, {
style: "percent",
minimumFractionDigits: contextPercentDigits,
maximumFractionDigits: contextPercentDigits,
});
const formatCompactTokenCount = (value: number) =>
formatNumber(value, {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: value < 10_000 ? 1 : 0,
});
const handleProjectValueChange = (value: string) => {
if (value === CREATE_PROJECT_VALUE) {
@ -166,6 +190,15 @@ export function ChatInputToolbar({
onProjectChange?.(value === NO_PROJECT_VALUE ? null : value);
};
const handleCompactContext = () => {
if (!canCompactContext || isCompactingContext || !onCompactContext) {
return;
}
setIsContextPopoverOpen(false);
void onCompactContext();
};
return (
<div className="flex items-center justify-between gap-2">
{/* Left side: pickers */}
@ -247,19 +280,70 @@ export function ChatInputToolbar({
)}
{contextLimit > 0 && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={t("toolbar.contextUsage")}
title={t("toolbar.contextUsageTitle", {
tokens: formatNumber(contextTokens),
limit: formatNumber(contextLimit),
})}
<Popover
open={isContextPopoverOpen}
onOpenChange={setIsContextPopoverOpen}
>
<ContextRing tokens={contextTokens} limit={contextLimit} />
</Button>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size={isCompact ? "icon-sm" : "sm"}
className={cn(
"group rounded-full bg-transparent text-foreground/80 shadow-none hover:bg-transparent hover:text-foreground data-[state=open]:bg-transparent data-[state=open]:text-foreground",
isCompact ? "px-0" : "px-2.5",
)}
aria-label={t("toolbar.contextUsage")}
title={t("toolbar.contextUsageTitle", {
tokens: formatNumber(contextTokens),
limit: formatNumber(contextLimit),
})}
>
<ContextRing
tokens={contextTokens}
limit={contextLimit}
size={18}
/>
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={8}
className="w-52 rounded-2xl p-1 text-left"
>
<div className="px-2 py-1.5 text-sm font-semibold text-foreground">
{t("toolbar.contextWindow")}
</div>
<div className="space-y-2 px-2 pb-1.5">
<Progress
className="h-1.5 bg-muted"
value={contextProgress * 100}
/>
<div className="flex items-center justify-between gap-3 text-xs text-foreground">
<div className="truncate">
{t("toolbar.contextTokensBreakdown", {
tokens: formatCompactTokenCount(contextTokens),
limit: formatCompactTokenCount(contextLimit),
})}
</div>
<div className="shrink-0">{usedPercentLabel}</div>
</div>
<Button
type="button"
variant="secondary"
size="xs"
className="w-full justify-center"
onClick={handleCompactContext}
disabled={!canCompactContext || isCompactingContext}
>
{isCompactingContext
? t("toolbar.compacting")
: t("toolbar.compactNow")}
</Button>
</div>
</PopoverContent>
</Popover>
)}
<DropdownMenu>

View file

@ -53,7 +53,6 @@ export function ChatView({
const { t } = useTranslation("chat");
const activeSessionId = sessionId;
const mountStart = useRef(performance.now());
// biome-ignore lint/correctness/useExhaustiveDependencies: log once on mount per session
useEffect(() => {
const ms = (performance.now() - mountStart.current).toFixed(1);
perfLog(`[perf:chatview] ${sessionId.slice(0, 8)} mounted in ${ms}ms`);
@ -363,6 +362,7 @@ export function ChatView({
chatState,
tokenState,
sendMessage,
compactConversation,
stopStreaming,
streamingMessageId,
} = useChat(
@ -453,6 +453,7 @@ export function ChatView({
onInitialMessageConsumed,
]);
const isStreaming = chatState === "streaming";
const isCompacting = chatState === "compacting";
const showIndicator =
chatState === "thinking" ||
chatState === "streaming" ||
@ -544,6 +545,13 @@ export function ChatView({
}
contextTokens={tokenState.accumulatedTotal}
contextLimit={tokenState.contextLimit}
onCompactContext={compactConversation}
canCompactContext={
chatState === "idle" &&
tokenState.accumulatedTotal > 0 &&
!projectMetadataPending
}
isCompactingContext={isCompacting}
/>
</div>

View file

@ -22,14 +22,6 @@ export function ContextRing({
const offset = circumference - progress * circumference;
const percent = formatNumber(Math.round(progress * 100));
// Color based on usage
const strokeColor =
progress > 0.9
? "var(--text-danger)"
: progress > 0.7
? "var(--text-warning)"
: "var(--text-muted)";
return (
<svg
width={size}
@ -38,29 +30,26 @@ export function ContextRing({
className="shrink-0"
aria-label={t("context.ringAria", { percent })}
>
{/* Background track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={2}
className="text-muted-foreground/30"
stroke="var(--color-border)"
strokeWidth={2.5}
/>
{/* Progress arc */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth={2}
strokeLinecap="round"
stroke="var(--color-foreground)"
strokeWidth={2.5}
strokeLinecap={progress > 0 ? "round" : "butt"}
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all duration-300"
className="transition-all duration-300 ease-out"
/>
</svg>
);

View file

@ -21,12 +21,12 @@ const LOADING_SHIMMER_REPEAT_DELAY_S = 0.9;
const MESSAGE_KEY_BY_STATE: Record<
Exclude<LoadingChatState, "idle">,
"thinking" | "responding"
"thinking" | "responding" | "compacting"
> = {
thinking: "thinking",
streaming: "responding",
waiting: "responding",
compacting: "responding",
compacting: "compacting",
};
export function LoadingGoose({ chatState = "idle" }: LoadingGooseProps) {

View file

@ -223,6 +223,49 @@ describe("ChatInput", () => {
expect(screen.getByText("goose2")).toBeInTheDocument();
});
it("opens a context usage popover when token tracking is available", async () => {
const user = userEvent.setup();
render(
<ChatInput onSend={vi.fn()} contextTokens={1536} contextLimit={8192} />,
);
await user.click(screen.getByRole("button", { name: /context usage/i }));
expect(screen.getByText("Context window")).toBeInTheDocument();
expect(screen.getByText("1.5K / 8.2K tokens used")).toBeInTheDocument();
expect(screen.getByText("19%")).toBeInTheDocument();
});
it("runs compaction from the context usage popover", async () => {
const user = userEvent.setup();
const onCompactContext = vi.fn();
render(
<ChatInput
onSend={vi.fn()}
contextTokens={1536}
contextLimit={8192}
canCompactContext
onCompactContext={onCompactContext}
/>,
);
await user.click(screen.getByRole("button", { name: /context usage/i }));
await user.click(screen.getByRole("button", { name: "Compact" }));
expect(onCompactContext).toHaveBeenCalledOnce();
});
it("hides the context usage control when the context limit is unavailable", () => {
render(
<ChatInput onSend={vi.fn()} contextTokens={1536} contextLimit={0} />,
);
expect(
screen.queryByRole("button", { name: /context usage/i }),
).not.toBeInTheDocument();
});
it("shows stop button when streaming", () => {
render(<ChatInput onSend={vi.fn()} onStop={vi.fn()} isStreaming />);
expect(

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { LoadingGoose } from "../LoadingGoose";
import chat from "@/shared/i18n/locales/en/chat.json";
const { thinking, responding } = chat.loading;
const { thinking, responding, compacting } = chat.loading;
describe("LoadingGoose", () => {
it("renders thinking copy for the thinking state", () => {
@ -23,10 +23,13 @@ describe("LoadingGoose", () => {
expect(
screen.getByRole("status", { name: responding }),
).toBeInTheDocument();
});
it("renders compacting copy for the compacting state", () => {
render(<LoadingGoose chatState="compacting" />);
rerender(<LoadingGoose chatState="compacting" />);
expect(
screen.getByRole("status", { name: responding }),
screen.getByRole("status", { name: compacting }),
).toBeInTheDocument();
});

View file

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockLoadSession = vi.fn();
vi.mock("../acpApi", () => ({
listProviders: vi.fn(),
prompt: vi.fn(),
setModel: vi.fn(),
listSessions: vi.fn(),
loadSession: (...args: unknown[]) => mockLoadSession(...args),
exportSession: vi.fn(),
importSession: vi.fn(),
forkSession: vi.fn(),
cancelSession: vi.fn(),
}));
vi.mock("../acpNotificationHandler", () => ({
setActiveMessageId: vi.fn(),
clearActiveMessageId: vi.fn(),
}));
vi.mock("../sessionSearch", () => ({
searchSessionsViaExports: vi.fn(),
}));
describe("acpLoadSession", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("restores the prior session mapping when replay loading fails", async () => {
mockLoadSession.mockRejectedValueOnce(new Error("load failed"));
const sessionTracker = await import("../acpSessionTracker");
const { acpLoadSession } = await import("../acp");
sessionTracker.registerSession(
"local-session",
"goose-session-1",
"goose",
"/tmp/original",
);
await expect(
acpLoadSession("local-session", "goose-session-2", "/tmp/replay"),
).rejects.toThrow("load failed");
expect(sessionTracker.getGooseSessionId("local-session")).toBe(
"goose-session-1",
);
expect(sessionTracker.getLocalSessionId("goose-session-1")).toBe(
"local-session",
);
expect(sessionTracker.getLocalSessionId("goose-session-2")).toBeNull();
});
});

View file

@ -146,17 +146,22 @@ export async function acpLoadSession(
const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts";
const sid = sessionId.slice(0, 8);
const t0 = performance.now();
perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`);
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
perfLog(
`[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`,
);
sessionTracker.registerSession(
const rollbackSessionRegistration = sessionTracker.registerSession(
sessionId,
gooseSessionId,
"goose",
effectiveWorkingDir,
);
try {
perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`);
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
perfLog(
`[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`,
);
} catch (error) {
rollbackSessionRegistration();
throw error;
}
}
/** Export a session as JSON via the goose binary. */

View file

@ -132,11 +132,23 @@ export async function cancelSession(sessionId: string): Promise<void> {
export async function newSession(
workingDir: string,
providerId?: string,
): Promise<NewSessionResponse> {
const tClient = performance.now();
const client = await getClient();
const request: Parameters<typeof client.newSession>[0] & {
meta?: Record<string, string>;
} = {
cwd: workingDir,
mcpServers: [],
};
if (providerId) {
request.meta = { provider: providerId };
}
const tCall = performance.now();
const response = await client.newSession({ cwd: workingDir, mcpServers: [] });
const response = await client.newSession(request);
const sid = response.sessionId.slice(0, 8);
perfLog(
`[perf:api] ${sid} newSession getClient=${(tCall - tClient).toFixed(1)}ms wire=${(performance.now() - tCall).toFixed(1)}ms`,

View file

@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
clearReplayBuffer,
getAndDeleteReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { registerSession } from "./acpSessionTracker";
import { handleSessionNotification } from "./acpNotificationHandler";
describe("acpNotificationHandler", () => {
beforeEach(() => {
clearReplayBuffer("draft-session-1");
clearReplayBuffer("draft-session-2");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
scrollTargetMessageBySession: {},
});
});
it("buffers usage updates until the local session mapping is registered", async () => {
const notification = {
sessionId: "goose-session-1",
update: {
sessionUpdate: "usage_update",
used: 512,
size: 8192,
},
} as SessionNotification;
await handleSessionNotification(notification);
expect(
useChatStore.getState().sessionStateById["draft-session-1"],
).toBeUndefined();
expect(
useChatStore.getState().sessionStateById["goose-session-1"],
).toBeUndefined();
registerSession("draft-session-1", "goose-session-1", "goose", "/tmp");
const runtime = useChatStore
.getState()
.getSessionRuntime("draft-session-1");
expect(runtime.tokenState.accumulatedTotal).toBe(512);
expect(runtime.tokenState.contextLimit).toBe(8192);
});
it("does not buffer non-usage updates before the local session mapping exists", async () => {
const notification = {
sessionId: "goose-session-2",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "message-1",
content: {
type: "text",
text: "hello from replay",
},
},
} as SessionNotification;
await handleSessionNotification(notification);
registerSession("draft-session-2", "goose-session-2", "goose", "/tmp");
expect(getAndDeleteReplayBuffer("draft-session-2")).toBeUndefined();
expect(
useChatStore.getState().messagesBySession["draft-session-2"],
).toBeUndefined();
});
});

View file

@ -14,11 +14,55 @@ import type {
ToolResponseContent,
} from "@/shared/types/messages";
import type { AcpNotificationHandler } from "./acpConnection";
import { getLocalSessionId } from "./acpSessionTracker";
import {
getLocalSessionId,
subscribeToSessionRegistration,
} from "./acpSessionTracker";
import { perfLog } from "@/shared/lib/perfLog";
// Pre-set message ID for the next live stream per goose session
const presetMessageIds = new Map<string, string>();
const pendingUsageUpdates = new Map<string, SessionUpdate[]>();
function shouldBufferPendingUpdate(update: SessionUpdate): boolean {
return update.sessionUpdate === "usage_update";
}
function queuePendingUsageUpdate(
gooseSessionId: string,
update: SessionUpdate,
): void {
const pending = pendingUsageUpdates.get(gooseSessionId);
if (pending) {
pending.push(update);
return;
}
pendingUsageUpdates.set(gooseSessionId, [update]);
}
function flushPendingUsageUpdates(
localSessionId: string,
gooseSessionId: string,
): void {
const pending = pendingUsageUpdates.get(gooseSessionId);
if (!pending?.length) {
return;
}
pendingUsageUpdates.delete(gooseSessionId);
for (const update of pending) {
if (useChatStore.getState().loadingSessionIds.has(localSessionId)) {
handleReplay(localSessionId, update);
} else {
handleLive(localSessionId, gooseSessionId, update);
}
}
}
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
flushPendingUsageUpdates(localSessionId, gooseSessionId);
});
// Per-session perf counters for replay/live streaming.
interface ReplayPerf {
@ -67,22 +111,32 @@ export async function handleSessionNotification(
notification: SessionNotification,
): Promise<void> {
const gooseSessionId = notification.sessionId;
const sessionId = getLocalSessionId(gooseSessionId) ?? gooseSessionId;
const { update } = notification;
const isReplay = useChatStore.getState().loadingSessionIds.has(sessionId);
const localSessionId = getLocalSessionId(gooseSessionId);
if (!localSessionId) {
if (shouldBufferPendingUpdate(update)) {
queuePendingUsageUpdate(gooseSessionId, update);
}
return;
}
const isReplay = useChatStore
.getState()
.loadingSessionIds.has(localSessionId);
if (isReplay) {
const sid = sessionId.slice(0, 8);
let perf = replayPerf.get(sessionId);
const sid = localSessionId.slice(0, 8);
let perf = replayPerf.get(localSessionId);
const now = performance.now();
if (!perf) {
perf = { firstAt: now, lastAt: now, count: 0 };
replayPerf.set(sessionId, perf);
replayPerf.set(localSessionId, perf);
perfLog(`[perf:replay] ${sid} first notification received`);
}
perf.lastAt = now;
perf.count += 1;
handleReplay(sessionId, update);
handleReplay(localSessionId, update);
} else {
const perf = livePerf.get(gooseSessionId);
if (perf && update.sessionUpdate === "agent_message_chunk") {
@ -95,7 +149,7 @@ export async function handleSessionNotification(
);
}
}
handleLive(sessionId, gooseSessionId, update);
handleLive(localSessionId, gooseSessionId, update);
}
}

View file

@ -7,8 +7,26 @@ interface PreparedSession {
workingDir: string;
}
type SessionRegistrationListener = (
localSessionId: string,
gooseSessionId: string,
) => void;
const prepared = new Map<string, PreparedSession>();
const gooseToLocal = new Map<string, string>();
const registrationListeners = new Set<SessionRegistrationListener>();
function restoreGooseRegistration(
gooseSessionId: string,
localSessionId: string | undefined,
): void {
if (localSessionId === undefined) {
gooseToLocal.delete(gooseSessionId);
return;
}
gooseToLocal.set(gooseSessionId, localSessionId);
}
function makeKey(sessionId: string, personaId?: string): string {
if (personaId && personaId.length > 0) {
@ -17,6 +35,22 @@ function makeKey(sessionId: string, personaId?: string): string {
return sessionId;
}
function notifySessionRegistered(
localSessionId: string,
gooseSessionId: string,
): void {
for (const listener of registrationListeners) {
listener(localSessionId, gooseSessionId);
}
}
export function subscribeToSessionRegistration(
listener: SessionRegistrationListener,
): () => void {
registrationListeners.add(listener);
return () => registrationListeners.delete(listener);
}
export async function prepareSession(
sessionId: string,
providerId: string,
@ -67,7 +101,7 @@ export async function prepareSession(
if (!gooseSessionId) {
const tNew = performance.now();
const response = await acpApi.newSession(workingDir);
const response = await acpApi.newSession(workingDir, providerId);
gooseSessionId = response.sessionId;
perfLog(
`[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`,
@ -84,6 +118,7 @@ export async function prepareSession(
prepared.set(key, { gooseSessionId, providerId, workingDir });
prepared.set(sessionId, { gooseSessionId, providerId, workingDir });
gooseToLocal.set(gooseSessionId, sessionId);
notifySessionRegistered(sessionId, gooseSessionId);
return gooseSessionId;
}
@ -109,8 +144,54 @@ export function registerSession(
gooseSessionId: string,
providerId: string,
workingDir: string,
): void {
): () => void {
const previousEntry = prepared.get(sessionId);
const previousGooseSessionLocal = gooseToLocal.get(gooseSessionId);
const previousSessionGooseLocal = previousEntry
? gooseToLocal.get(previousEntry.gooseSessionId)
: undefined;
const entry = { gooseSessionId, providerId, workingDir };
if (
previousEntry &&
previousEntry.gooseSessionId !== gooseSessionId &&
gooseToLocal.get(previousEntry.gooseSessionId) === sessionId
) {
gooseToLocal.delete(previousEntry.gooseSessionId);
}
prepared.set(sessionId, entry);
gooseToLocal.set(gooseSessionId, sessionId);
notifySessionRegistered(sessionId, gooseSessionId);
return () => {
prepared.delete(sessionId);
if (previousEntry) {
prepared.set(sessionId, previousEntry);
}
restoreGooseRegistration(gooseSessionId, previousGooseSessionLocal);
if (previousEntry && previousEntry.gooseSessionId !== gooseSessionId) {
restoreGooseRegistration(
previousEntry.gooseSessionId,
previousSessionGooseLocal,
);
}
};
}
export function unregisterSession(
sessionId: string,
gooseSessionId?: string,
): void {
const entry = prepared.get(sessionId);
prepared.delete(sessionId);
const resolvedGooseSessionId = gooseSessionId ?? entry?.gooseSessionId;
if (
resolvedGooseSessionId &&
gooseToLocal.get(resolvedGooseSessionId) === sessionId
) {
gooseToLocal.delete(resolvedGooseSessionId);
}
}

View file

@ -111,6 +111,7 @@
"placeholder": "Message {{agent}}, @ to mention personas"
},
"loading": {
"compacting": "Compacting conversation...",
"thinking": "Thinking...",
"responding": "Responding..."
},
@ -151,9 +152,14 @@
"attachFolder": "Folder",
"chooseAgentModel": "Choose agent and model",
"chooseProject": "Choose a project",
"compactNow": "Compact",
"compacting": "Compacting...",
"chooseProvider": "Choose a provider",
"contextTokensBreakdown": "{{tokens}} / {{limit}} tokens used",
"contextUsage": "Context usage",
"contextUsageBreakdown": "{{used}} used ({{left}} left)",
"contextUsageTitle": "{{tokens}} / {{limit}} tokens",
"contextWindow": "Context window",
"createProject": "Create project",
"generalChatWithoutProject": "General chat without project context",
"loading": "Loading...",

View file

@ -111,6 +111,7 @@
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar personas"
},
"loading": {
"compacting": "Compactando conversación...",
"thinking": "Pensando...",
"responding": "Respondiendo..."
},
@ -151,9 +152,14 @@
"attachFolder": "Carpeta",
"chooseAgentModel": "Elegir agente y modelo",
"chooseProject": "Elegir un proyecto",
"compactNow": "Compactar",
"compacting": "Compactando...",
"chooseProvider": "Elegir un proveedor",
"contextTokensBreakdown": "{{tokens}} / {{limit}} tokens usados",
"contextUsage": "Uso del contexto",
"contextUsageBreakdown": "{{used}} en uso ({{left}} libres)",
"contextUsageTitle": "{{tokens}} / {{limit}} tokens",
"contextWindow": "Ventana de contexto",
"createProject": "Crear proyecto",
"generalChatWithoutProject": "Chat general sin contexto de proyecto",
"loading": "Cargando...",

View file

@ -18,7 +18,7 @@ const buttonVariants = cva(
"outline-flat":
"border border-input bg-background shadow-none hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
"ghost-light":
"font-normal hover:bg-accent hover:text-accent-foreground",