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
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,14 @@ class BuddyTeamMessage(
val createdAt: Instant = Instant.now(),
@Column(nullable = false)
val opening: Boolean = false,
/**
* The team areas this reply opened, as a comma-separated list of area names; null when it opened
* none, and for every message written before the column existed.
*
* Read back on the manager's *next* message so that an area opened to draft something is still
* open when they say "yes, send it". The transcript holds text only, so without this the tools that
* make the change are gone by the time the manager approves, and the model has nothing to call.
*/
@Column(name = "opened_areas", nullable = true)
val openedAreas: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,15 @@ class BuddyTeamService(
val session = getOrCreateSession(userId, projectId)

// Read before saving the new message so it is not sent to the AI twice.
val history = buddyTeamMessageRepository
.findAllBySessionIdOrderByCreatedAtAsc(session.id)
.drop(session.summarizedCount)
.map { it.toAgentMessage() }
val transcript = buddyTeamMessageRepository.findAllBySessionIdOrderByCreatedAtAsc(session.id)
val history = transcript.drop(session.summarizedCount).map { it.toAgentMessage() }
// The last reply is the one this message answers, so it decides what is still open. It is the whole
// transcript, not the unfolded part: a fold must not close what the last reply opened.
val carriedOver = transcript
.lastOrNull()
?.takeIf { it.role == BuddyMessageRole.ASSISTANT }
?.openedAreas
.toTeamAreas()

buddyTeamMessageRepository.save(
BuddyTeamMessage(session = session, role = BuddyMessageRole.USER, content = content),
Expand All @@ -160,12 +165,13 @@ class BuddyTeamService(
var citations: List<BuddyCitationDto> = emptyList()
var answer: String? = null
var step = 0
val openedAreas = mutableSetOf<TeamArea>()
val areas = OpenAreas(carriedOver)

while (answer == null && step < BuddyService.MAX_AGENT_STEPS) {
step++
// Per hop, not per turn: an area opened on the previous hop is mounted from this one.
val tools = if (capabilitiesEnabled) buddyTeamTools.toolSpecs(openedAreas) else emptyList()
// Per hop, not per turn: an area opened on the previous hop is mounted from this one, and
// one opened by the previous *reply* is mounted from the first.
val tools = if (capabilitiesEnabled) buddyTeamTools.toolSpecs(areas.mounted) else emptyList()
val response = onboardingAiClient.buddyAgentTurn(
BuddyAgentRequest(
messages = messages,
Expand All @@ -188,7 +194,7 @@ class BuddyTeamService(
next.add(
BuddyAgentMessageDto(
role = "tool",
content = runToolCall(call, context, mounted, openedAreas),
content = runToolCall(call, context, mounted, areas),
toolCallId = call.id,
),
)
Expand All @@ -201,7 +207,14 @@ class BuddyTeamService(
emitAgentReply(reply, citations)

buddyTeamMessageRepository.save(
BuddyTeamMessage(session = session, role = BuddyMessageRole.ASSISTANT, content = reply),
BuddyTeamMessage(
session = session,
role = BuddyMessageRole.ASSISTANT,
content = reply,
// Only what this reply opened, not what it inherited: an area stays open for one more
// message, so a conversation does not slowly mount every area's tools.
openedAreas = areas.openedThisTurn.encoded(),
),
)
Comment on lines 207 to 218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still makes the fix conditional on the model choosing to call open_area.

areas.openedThisTurn is populated only by an open_area tool call. The live reproduction shows that the model can instead return a draft or nonsensical final answer without opening KNOWLEDGE. This line then stores null, so the manager’s next message - such as “send that draft” - again has no escalation action tool mounted. The original bug therefore remains possible and has already reproduced against this head.

The new open_area description is helpful guidance, but prompt text is not a reliable state transition or enforcement mechanism.

Please make area continuation deterministic on the backend. For example, require a structured area-selection/tool step before accepting an area-dependent final response, or otherwise prevent an actionable workflow from finalizing until the relevant area has been opened or an action proposal has actually been emitted. A response must also never be allowed to claim that an action was completed unless the backend created/executed the corresponding proposal.

compactInBackground(userId, projectId)
}
Expand All @@ -219,7 +232,7 @@ class BuddyTeamService(
call: BuddyToolCallDto,
context: TeamToolContext,
mountedToolNames: Set<String>,
openedAreas: MutableSet<TeamArea>,
areas: OpenAreas,
): String {
if (call.name in mountedToolNames && buddyProposalService.isAction(call.name)) {
val outcome = buddyProposalService.propose(call, context)
Expand All @@ -240,7 +253,7 @@ class BuddyTeamService(
emit(BuddyStreamEvent(type = "tool_use", name = call.name, kind = "tool"))
if (call.name == BuddyTeamTools.OPEN_AREA && call.name in mountedToolNames) {
val outcome = buddyTeamTools.openArea(call)
outcome.area?.let { openedAreas.add(it) }
outcome.area?.let { areas.open(it) }
return outcome.toolResult
}
return buddyTeamTools.execute(call, context, mountedToolNames)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ class BuddyTeamTools(
name = OPEN_AREA,
description = "Open one area of the manager's work so its tools become available on your next " +
"step. Open an area only when the manager asks about something in it; its tools are not " +
"available until you have opened it.",
"available until you have opened it. An area stays open for the manager's next message too, " +
"and no longer: if they ask you to act on something from earlier and the tool is not there, " +
"open the area again first. Never say something has been offered for confirmation unless a " +
"tool of an opened area did it.\n\nThe areas:\n" +
openableAreas().sorted().joinToString("\n") { "- ${it.name.lowercase()}: ${it.summary}" },
parameters = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.sprintstart.sprintstartbackend.onboarding.service

/**
* The team areas whose tools are mounted while one manager message is answered.
*
* Two things put an area here. The manager's previous message may have opened it ([carriedOver]), which is
* what makes "yes, send it" work: drafting something opens an area, approving it comes a message later,
* and the transcript is text only, so nothing else remembers that the area was open. And the model can open
* one itself during this turn ([open]).
*
* Only what this turn opened is remembered for the next one ([openedThisTurn]). What was carried over is not
* carried again, so an area stays open for one further message and a long conversation does not slowly
* mount every area's tools — the reason areas exist.
*/
internal class OpenAreas(
carriedOver: Set<TeamArea> = emptySet(),
) {
/** Every area mounted so far, this turn's and the last one's. Read again on each hop. */
val mounted: MutableSet<TeamArea> = carriedOver.toMutableSet()

/** The areas the model opened during this turn. */
val openedThisTurn: MutableSet<TeamArea> = mutableSetOf()

fun open(area: TeamArea) {
mounted.add(area)
openedThisTurn.add(area)
}
}

/** The stored form of the areas a reply opened: their names, comma-separated and sorted; null when none. */
internal fun Set<TeamArea>.encoded(): String? =
takeIf { it.isNotEmpty() }?.sortedBy { it.name }?.joinToString(",") { it.name }

/** The areas a stored [encoded] value names. A name that is no longer an area is dropped, never an error. */
internal fun String?.toTeamAreas(): Set<TeamArea> =
this
?.split(',')
?.mapNotNull { name -> TeamArea.entries.firstOrNull { it.name == name.trim() } }
?.toSet()
.orEmpty()
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@ import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCal
import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto
import java.util.UUID

/** A part of the manager's surface the team-mode buddy can open, each with its own tools. */
enum class TeamArea {
KNOWLEDGE,
STARTER_WORK,
TEAM,
ARRIVAL,
CONTENT,
SOURCES,
/**
* A part of the manager's surface the team-mode buddy can open, each with its own tools.
*
* @property summary What is inside, in the manager's words. The model chooses an area from its name and
* this alone, and a name is not enough: "knowledge" does not say that hires' escalated questions live there.
*/
enum class TeamArea(
val summary: String,
) {
KNOWLEDGE("questions hires escalated because nobody could answer them, and the canonical answers written for them"),
STARTER_WORK("the pool of starter-work tasks hires are offered, and GitHub issues that could join it"),
TEAM("who is on the project and which roles they hold: adding and removing people, giving and taking roles"),
ARRIVAL("what has to be true before a new hire can start working, as a list the project owns"),
CONTENT(
"the onboarding paths of the project's members: their phases, steps, tasks and links, skip requests, " +
"feedback, knowledge checks and orientation packets",
),
SOURCES("where the project's material comes from: repositories, other connected sources and uploads"),
}

/** Who a team-mode tool runs for, and on which project. Resolved and authorised before any tool runs. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- The team areas a buddy reply opened, so the manager's next message still has the tools that area mounts.
-- Nullable on purpose: existing messages opened nothing that matters, and Hibernate (ddl-auto: update) can add
-- a nullable column to a populated table, which it cannot do for NOT NULL ones. Idempotent.
ALTER TABLE buddy_team_messages
ADD COLUMN IF NOT EXISTS opened_areas VARCHAR(255);
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.sprintstart.sprintstartbackend.onboarding.repository

import com.sprintstart.sprintstartbackend.onboarding.external.enums.BuddyMessageRole
import com.sprintstart.sprintstartbackend.onboarding.model.entity.BuddyTeamMessage
import com.sprintstart.sprintstartbackend.onboarding.model.entity.BuddyTeamSession
import com.sprintstart.sprintstartbackend.shared.crypto.CryptoConfiguration
import jakarta.persistence.EntityManager
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import java.time.Instant
import java.util.UUID

/**
* What a team reply opened has to survive the trip to the database and back: the next message reads it to
* decide which tools to mount, and a value that came back changed or empty would silently bring the
* "yes, send it" failure back.
*/
@ActiveProfiles("test")
@DataJpaTest
@Import(CryptoConfiguration::class)
class BuddyTeamMessageRepositoryTest {
@Autowired
private lateinit var repository: BuddyTeamMessageRepository

@Autowired
private lateinit var entityManager: EntityManager

private fun session(): BuddyTeamSession =
BuddyTeamSession(userId = UUID.randomUUID(), projectId = UUID.randomUUID()).also { entityManager.persist(it) }

private fun message(session: BuddyTeamSession, offsetSeconds: Long, opened: String?) =
BuddyTeamMessage(
session = session,
role = BuddyMessageRole.ASSISTANT,
content = "reply $offsetSeconds",
createdAt = Instant.parse("2026-09-21T10:00:00Z").plusSeconds(offsetSeconds),
openedAreas = opened,
)

@Test
fun `the areas a reply opened come back exactly as stored, and none comes back as null`() {
val session = session()
repository.save(message(session, 1, "ARRIVAL,KNOWLEDGE"))
repository.save(message(session, 2, null))
entityManager.flush()
entityManager.clear()

val stored = repository.findAllBySessionIdOrderByCreatedAtAsc(session.id)

assertThat(stored.map { it.openedAreas }).containsExactly("ARRIVAL,KNOWLEDGE", null)
}
}
Loading
Loading